Skip to content
Get Started for Free

Pulumi

Pulumi is an infrastructure-as-code platform that lets you create, deploy, and manage cloud infrastructure using general-purpose programming languages, such as Python, TypeScript, Go, C#, and Java. It supports a range of cloud providers, including AWS, Azure, Google Cloud, and Kubernetes. On Azure, the Azure Native provider (azure-native) manages resources through the Azure Resource Manager (ARM) API.

LocalStack for Azure integrates with Pulumi through the configuration of the Azure Native provider. Instead of configuring an endpoint for each Azure service, you point the provider to the metadata endpoint of the emulator, and the provider discovers the login and Azure Resource Manager endpoints of the emulator from it. There are two main methods to configure Pulumi for use with LocalStack for Azure:

  • Using a Pulumi ESC environment that injects the provider settings into each Pulumi operation as ARM_* environment variables. This method requires the Pulumi Cloud backend.
  • Storing the provider settings as azure-native:* keys in the stack configuration. This method works with any backend, including a local backend.

This guide shows you how to deploy a resource group, a storage account, and a blob container to the LocalStack for Azure emulator with a Python Pulumi program, using either method.

This guide is designed for users who are new to LocalStack for Azure and assumes basic knowledge of Pulumi.

By default, Pulumi stores the state of your stacks in Pulumi Cloud. Optionally, you can store the state on your machine by setting the following environment variables before you create the project:

Terminal window
export PULUMI_BACKEND_URL=file://~
export PULUMI_CONFIG_PASSPHRASE=lsdevtest

PULUMI_BACKEND_URL stores the state under ~/.pulumi, like pulumi login --local does, and PULUMI_CONFIG_PASSPHRASE sets the passphrase that encrypts the secrets in the stack configuration. Pulumi ESC requires the Pulumi Cloud backend, so with a local backend, configure the provider with stack configuration.

Create a directory for the project, and create the project and the dev stack from the python template:

Terminal window
mkdir my-azure-infra
cd my-azure-infra
pulumi new python --name my-azure-infra --description "This is a sample Pulumi project" --stack dev --runtime-options toolchain=uv --yes

The --runtime-options toolchain=uv option makes Pulumi use uv to create the virtual environment and install the dependencies, and --yes skips the remaining prompts:

Output
Created project 'my-azure-infra'
Created stack 'dev'
Installing dependencies...
Using CPython 3.13.2 interpreter at: ...
Creating virtual environment at: .venv
...
Finished installing dependencies
Your new project is ready to go!
To perform an initial deployment, run `pulumi up`

The command creates the following files in the project directory:

  • Pulumi.yaml: The project definition, including the language runtime and the toolchain.
  • __main__.py: The Pulumi program.
  • pyproject.toml and uv.lock: The Python dependencies of the project.
  • .venv: The virtual environment that runs the program.

The python template doesn’t include the Azure Native provider. Add the pulumi-azure-native package to the project:

Terminal window
uv add pulumi-azure-native

The command installs the package into the .venv virtual environment and records it in pyproject.toml and uv.lock.

Replace the contents of __main__.py with the following code. The program creates a resource group, a storage account, and a blob container, and exports their names as stack outputs:

__main__.py
import pulumi_azure_native.resources as resource
import pulumi_azure_native.storage as storage
from pulumi import Config, export
from pulumi_azure_native.storage import BlobContainer, PublicAccess
config = Config()
prefix = config.get("prefix", "localstack")
suffix = config.get("suffix", "test")
container_name = config.get("container_name", "documents")
resource_group = resource.ResourceGroup(
"appservicerg",
resource_group_name=f"{prefix}-rg"
)
storage_account = storage.StorageAccount(
"appservicesa",
account_name=f"{prefix}storage{suffix}",
resource_group_name=resource_group.name,
kind=storage.Kind.STORAGE_V2,
sku=storage.SkuArgs(name=storage.SkuName.STANDARD_LRS),
)
storage_container = BlobContainer(
"appservice-c",
container_name=container_name,
account_name=storage_account.name,
public_access=PublicAccess.NONE,
resource_group_name=resource_group.name,
)
export("resourceGroupName", resource_group.name)
export("storageAccountName", storage_account.name)
export("containerName", storage_container.name)

The program reads the prefix, suffix, and container_name values from the stack configuration and sets the name of each resource explicitly. Without an explicit name, Pulumi generates one by appending a random suffix to the logical name of the resource. For more information, see Resource names.

Set the values that the program expects, and the Azure region of the resources, in the dev stack:

Terminal window
pulumi config set prefix pulumi
pulumi config set suffix test
pulumi config set container_name orders
pulumi config set azure-native:location westeurope

Pulumi saves stack-specific settings in the Pulumi.dev.yaml file in the project directory. Settings that apply to all the stacks of the project belong in Pulumi.yaml.

The name of the storage account combines the prefix and the suffix: pulumistoragetest. Storage account names must be between 3 and 24 characters long and contain only lowercase letters and numbers.

The Azure Native provider reads each setting from the stack configuration and, when the stack configuration doesn’t contain it, from the matching ARM_* environment variable. Configure the provider with one of the following methods. For a description of each setting, see Configuration.

Pulumi ESC stores the provider settings in Pulumi Cloud and injects them into the Pulumi operations of each stack that imports the environment.

Create an environment named azure-localstack:

Terminal window
pulumi env init azure-localstack

Without an organization or project prefix, Pulumi creates the environment in the default project of your default organization, so its fully qualified name is <your-org>/default/azure-localstack. Run pulumi whoami -v to list your organizations and pulumi env ls to list your environments.

Open the environment definition in your default terminal editor:

Terminal window
pulumi env edit azure-localstack

Add the ARM_* variables under the environmentVariables block, and encrypt the client secret with fn::secret:

values:
environmentVariables:
ARM_CLIENT_ID: "any-app"
ARM_TENANT_ID: "00000000-0000-0000-0000-000000000000"
ARM_SUBSCRIPTION_ID: "00000000-0000-0000-0000-000000000000"
ARM_METADATA_HOSTNAME: "azure.localhost.localstack.cloud:4566"
ARM_DISABLE_INSTANCE_DISCOVERY: "true"
ARM_CLIENT_SECRET:
fn::secret: "any-secret"

Attach the environment to the dev stack:

Terminal window
pulumi config env add azure-localstack --yes
Output
KEY VALUE
azure-native:location westeurope
container_name orders
prefix pulumi
suffix test
pulumi:tags {"pulumi:template":"python"}
ENVIRONMENT VARIABLE VALUE
ARM_CLIENT_ID any-app
ARM_CLIENT_SECRET [secret]
ARM_DISABLE_INSTANCE_DISCOVERY true
ARM_METADATA_HOSTNAME azure.localhost.localstack.cloud:4566
ARM_SUBSCRIPTION_ID 00000000-0000-0000-0000-000000000000
ARM_TENANT_ID 00000000-0000-0000-0000-000000000000

Use the environment name without your organization prefix. A stack can only import environments from its own organization, so Pulumi reads the first segment of a two-part name as the project: pulumi config env add <your-org>/azure-localstack fails with environment '<your-org>/<your-org>/azure-localstack' not found. If you created the environment in a project other than default, use <project-name>/azure-localstack.

The command appends an environment block to Pulumi.dev.yaml:

Pulumi.dev.yaml
config:
my-azure-infra:prefix: pulumi
my-azure-infra:suffix: test
my-azure-infra:container_name: orders
azure-native:location: westeurope
environment:
- azure-localstack

When you run a Pulumi command on the stack, Pulumi opens the environment and injects the ARM_* variables into the Pulumi program and the provider. The variables aren’t set in your shell.

To keep the provider settings with the stack, for example with a local backend, store them as azure-native:* keys in the stack configuration instead of an ESC environment:

Terminal window
pulumi config set azure-native:metadataHost azure.localhost.localstack.cloud:4566
pulumi config set azure-native:disableInstanceDiscovery true
pulumi config set azure-native:clientId any-app
pulumi config set azure-native:tenantId 00000000-0000-0000-0000-000000000000
pulumi config set azure-native:subscriptionId 00000000-0000-0000-0000-000000000000
pulumi config set --secret azure-native:clientSecret any-secret

Pulumi adds the keys to Pulumi.dev.yaml and encrypts the client secret. With a local backend, the file contains the following settings:

Pulumi.dev.yaml
encryptionsalt: v1:...
config:
my-azure-infra:prefix: pulumi
my-azure-infra:suffix: test
my-azure-infra:container_name: orders
azure-native:location: westeurope
azure-native:metadataHost: azure.localhost.localstack.cloud:4566
azure-native:disableInstanceDiscovery: "true"
azure-native:clientId: any-app
azure-native:tenantId: 00000000-0000-0000-0000-000000000000
azure-native:subscriptionId: 00000000-0000-0000-0000-000000000000
azure-native:clientSecret:
secure: v1:...

Start the LocalStack for Azure emulator:

Terminal window
lstk start

Pulumi needs no further setup to reach the emulator, because the provider settings already point the Azure Native provider to it.

The pulumi preview command runs your program, compares the resources it declares with the current state of the stack, and shows the operations Pulumi would perform, without creating, updating, or deleting anything. Each resource is marked with its planned operation, such as + for create, ~ for update, and - for delete. Stack outputs that depend on properties Azure returns after creating the resources are shown as [unknown].

Terminal window
pulumi preview

The output shows that Pulumi will create the stack and the three Azure resources:

Output
Previewing update (dev)
View in Browser (Ctrl+O): https://app.pulumi.com/...
Type Name Plan
+ pulumi:pulumi:Stack my-azure-infra-dev create
+ ├─ azure-native:resources:ResourceGroup appservicerg create
+ ├─ azure-native:storage:BlobContainer appservice-c create
+ └─ azure-native:storage:StorageAccount appservicesa create
Outputs:
containerName : [unknown]
resourceGroupName : [unknown]
storageAccountName: [unknown]
Resources:
+ 4 to create

A successful preview doesn’t confirm that Pulumi can authenticate against the emulator. In this project, the provider requests an access token only when it creates the resources during pulumi up.

The pulumi up command shows the same preview and asks for confirmation. Select yes to apply the changes:

Terminal window
pulumi up
Output
...
Updating (dev)
View in Browser (Ctrl+O): https://app.pulumi.com/...
Type Name Status
+ pulumi:pulumi:Stack my-azure-infra-dev created (5s)
+ ├─ azure-native:resources:ResourceGroup appservicerg created (0.43s)
+ ├─ azure-native:storage:StorageAccount appservicesa created (2s)
+ └─ azure-native:storage:BlobContainer appservice-c created (0.57s)
Outputs:
containerName : "orders"
resourceGroupName : "pulumi-rg"
storageAccountName: "pulumistoragetest"
Resources:
+ 4 created
Duration: 7s

To inspect the deployed resources with the Azure CLI, redirect the az commands to the emulator:

Terminal window
lstk az start-interception

This command changes your global Azure CLI configuration to use the LocalStack cloud until you run lstk az stop-interception. For more information, see Azure CLI.

Read the resource names from the stack outputs, and show the storage account:

Terminal window
export RESOURCE_GROUP=$(pulumi stack output resourceGroupName)
export STORAGE_ACCOUNT=$(pulumi stack output storageAccountName)
az storage account show \
--name "$STORAGE_ACCOUNT" \
--resource-group "$RESOURCE_GROUP" \
--query "{Name:name, Kind:kind, Sku:sku.name, Location:location, State:provisioningState}" \
--output table
Output
Name Kind Sku Location State
----------------- --------- ------------ ---------- ---------
pulumistoragetest StorageV2 Standard_LRS westeurope Succeeded

List the blob containers of the storage account:

Terminal window
az storage container-rm list \
--storage-account "$STORAGE_ACCOUNT" \
--resource-group "$RESOURCE_GROUP" \
--query "[].{Name:name, PublicAccess:publicAccess}" \
--output table
Output
Name PublicAccess
------ --------------
orders None

Delete the resources of the stack:

Terminal window
pulumi destroy

Like pulumi up, this command shows a preview before it performs any changes. Select yes to proceed. The stack still exists, with its configuration and history, but the emulator no longer contains its resources.

To remove the stack itself, including its configuration and history, run the following command and confirm the removal when prompted:

Terminal window
pulumi stack rm dev

To make the az commands target Azure again, stop the interception:

Terminal window
lstk az stop-interception

The following table lists the Azure Native provider settings that target the emulator. Set each one either as a stack configuration key or as an environment variable, for example in a Pulumi ESC environment. When both are set, the stack configuration key takes precedence.

Stack configuration key Environment variable Value Description
azure-native:metadataHost ARM_METADATA_HOSTNAME azure.localhost.localstack.cloud:4566 Host of the metadata endpoint, /metadata/endpoints, from which the provider discovers the login and Azure Resource Manager endpoints of the emulator.
azure-native:disableInstanceDiscovery ARM_DISABLE_INSTANCE_DISCOVERY true Stops the Azure Identity library from asking Microsoft Entra ID to validate the login endpoint of the emulator.
azure-native:clientId ARM_CLIENT_ID Any value, such as any-app. Client ID of the service principal.
azure-native:clientSecret ARM_CLIENT_SECRET Any value, such as any-secret. Client secret of the service principal.
azure-native:tenantId ARM_TENANT_ID 00000000-0000-0000-0000-000000000000 ID of the Microsoft Entra tenant.
azure-native:subscriptionId ARM_SUBSCRIPTION_ID 00000000-0000-0000-0000-000000000000 ID of the subscription that contains the resources.

The client secret makes the provider authenticate as a service principal, so it doesn’t depend on the Azure CLI. The emulator doesn’t validate the client ID and secret. For more information, see Credentials.

The provider reads only the environment variable names in the table. For example, with ARM_METADATA_HOST instead of ARM_METADATA_HOSTNAME, the provider ignores the metadata host and authenticates against the public Azure cloud. The provider disables instance discovery only when the value is exactly true.

For the complete list of settings, see Azure Native: Installation & Configuration.

If pulumi up fails with ClientSecretCredential authentication failed, the URL of the failing request shows which setting is missing. To check whether any request reaches the emulator, inspect the emulator logs with lstk logs.

AADSTS900021: Requested tenant identifier is not valid

Section titled “AADSTS900021: Requested tenant identifier is not valid”
Output
error: ClientSecretCredential authentication failed.
GET https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0/.well-known/openid-configuration
RESPONSE 400: 400 Bad Request
"error": "invalid_tenant",
"error_description": "AADSTS900021: Requested tenant identifier '00000000-0000-0000-0000-000000000000' is not valid. ..."

The provider authenticates against the public Azure cloud instead of the emulator, so no request reaches LocalStack. Make sure that the metadata host is set with the exact name, ARM_METADATA_HOSTNAME or azure-native:metadataHost.

Output
error: ClientSecretCredential authentication failed.
GET https://login.microsoftonline.com/common/discovery/instance
RESPONSE 400: 400 Bad Request
"error": "invalid_instance",

The provider reached the emulator, but the Azure Identity library asked Microsoft Entra ID to validate the login endpoint of the emulator before requesting a token. Set ARM_DISABLE_INSTANCE_DISCOVERY or azure-native:disableInstanceDiscovery to true.

Was this page helpful?