Azure Provider Configuration with Pulumi Python

Azure's resource model differs from AWS in ways that bite on day one: everything lives inside a resource group, identity flows through Entra ID service principals or managed identities, and two separate Pulumi providers cover the same cloud. This section, part of Pulumi patterns and provider management, covers choosing a provider, authenticating cleanly, and laying out Azure resources in typed Python. It also covers the three mechanics that produce most first-week failures on Azure — resource-provider registration, name validation, and the asynchronous nature of every Azure Resource Manager (ARM) write.

Problem Framing

Teams arriving from the AWS provider deep dive usually try to map Azure onto AWS concepts and stall on three differences. First, there is no implicit account boundary: a subscription holds resource groups, and every resource must name one. Second, Azure ships two Pulumi providers — pulumi-azure-native, generated from the Azure Resource Manager (ARM) API, and pulumi-azure, bridged from the Terraform provider — with different resource names for the same infrastructure. Third, credentials are not a single access-key pair; they are a tenant, a subscription, and a principal, each of which can be wrong independently.

Azure resource model Azure resource model: Subscription with 4 facets. Subscription Resource group required parent Tenant Entra ID directory Principal service identity Region per-resource
Every Azure resource names a resource group; identity is a tenant plus a principal, not one key pair.

Getting these settled before the first resource is written avoids a rewrite later. The choice of provider in particular is hard to reverse: the two providers name resources differently, so switching means recreating or re-importing every resource in the stack.

There is a fourth difference that is less obvious and causes more wasted hours than the other three combined. ARM is a single control-plane API in front of dozens of independently deployed services, each of which is a resource providerMicrosoft.Storage, Microsoft.Network, Microsoft.ContainerService, Microsoft.Web. ARM does not accept writes for a resource provider until that resource provider has been switched on for the subscription, most writes complete asynchronously behind a polling URL, and the directory that answers "does this principal exist?" is replicated separately from the directory that issued the token. Pulumi surfaces all three as ordinary resource errors, which makes them look like program bugs when they are subscription state.

Prerequisites

  • Python 3.9+ with pulumi>=3.0 and one of pulumi-azure-native>=2.0 or pulumi-azure>=5.0 pinned
  • The Azure CLI installed and logged in, or a service principal's tenant/client/subscription values available as environment variables
  • Contributor rights on the target subscription, plus the ability to create role assignments if the stack grants identities access
  • Microsoft.Resources/subscriptions/providers/register/action on the subscription, or a platform team that has pre-registered the resource providers your stack touches
# CLI: confirm the CLI is authenticated and which subscription is active
az account show --query '[name, id, tenantId]' -o tsv
pulumi plugin ls | grep -i azure

The last bullet is the one most often missing. A read-only Reader role, or a narrowly scoped custom role that grants writes only inside one resource group, cannot register a resource provider — and the failure appears on the resource you were trying to create rather than on the subscription.

Choosing Between the Two Providers

pulumi-azure-native is generated directly from the ARM API, so it exposes every service the day the API does and matches the property names in Azure's own documentation. pulumi-azure is bridged from the Terraform provider and offers friendlier defaults and a few conveniences the raw API lacks, but it lags the API and occasionally abstracts away fields you need.

Provider comparison Provider comparison: comparison across azure-native, azure (bridged). Criterion azure-native azure (bridged) API coverage complete, same-day lags releases Naming matches ARM docs Terraform names Defaults explicit, verbose convenience defaults Best for new stacks Terraform ports
The native provider tracks the ARM API; the bridged provider trades coverage for convenience.

The practical rule: choose azure-native for new work, and reach for pulumi-azure only when you are porting existing Terraform or need one of its higher-level resources. Do not mix them for the same resource — two providers managing one object produce conflicting updates.

One structural detail matters when you pin versions. Every ARM resource type is versioned by an api-version query parameter, and pulumi-azure-native mirrors that: the top-level module (pulumi_azure_native.storage.StorageAccount) tracks a default API version chosen by the provider release, while dated submodules expose specific versions. Pinning a dated submodule freezes both the schema and the wire version, which is what you want for a resource whose newer API versions change defaults.

# providers_compared.py — the same storage account under both providers
# CLI: pulumi preview
import pulumi_azure_native as azure_native
import pulumi_azure_native.storage.v20230101 as storage_v1
import pulumi_azure as azure_classic

# Native provider, provider-chosen default API version.
default_version = azure_native.storage.StorageAccount(
    "assets-default",
    resource_group_name="platform-prod",
    sku=azure_native.storage.SkuArgs(name="Standard_LRS"),
    kind="StorageV2",
)

# Native provider, pinned API version — the schema will not shift under a provider upgrade.
pinned_version = storage_v1.StorageAccount(
    "assets-pinned",
    resource_group_name="platform-prod",
    sku=storage_v1.SkuArgs(name="Standard_LRS"),
    kind="StorageV2",
)
# Provider note: dated submodules pin the ARM api-version sent on the wire.

# Bridged provider — different type name, different argument names, same Azure object.
bridged = azure_classic.storage.Account(
    "assets-bridged",
    resource_group_name="platform-prod",
    account_tier="Standard",
    account_replication_type="LRS",
)
# State implication: these are three separate URNs. Pointing two of them at one
# Azure account name fails with StorageAccountAlreadyTaken, not with a merge.

How ARM Resource-Provider Registration Works

Every ARM resource type belongs to a namespace, and a subscription must have that namespace registered before ARM will route a write to it. Registration is a per-subscription, one-time, asynchronous operation: ARM asks the resource provider to provision its subscription-level footprint, and the namespace moves from NotRegistered through Registering to Registered. Newly created subscriptions register a common set automatically, but anything outside that set — Kubernetes, Functions, Cognitive Services, Front Door — is not registered until someone asks.

Resource provider registration Resource provider registration: Pulumi provider → ARM endpoint → Microsoft.Web. Pulumi provider ARM endpoint Microsoft.Web PUT resource RP registered? 409 conflict register RP provision RP state Registered 201 Created
A fresh subscription rejects the first PUT until the owning resource provider is registered.

When the namespace is missing, the first pulumi up in that subscription fails on the resource, not on the subscription, with a message that names the namespace:

# CLI: the error Pulumi surfaces on a fresh subscription
# error: Code="MissingSubscriptionRegistration"
#   Message="The subscription is not registered to use namespace 'Microsoft.Web'."
#   Details=[{"code":"MissingSubscriptionRegistration","target":"Microsoft.Web"}]
az provider show --namespace Microsoft.Web --query registrationState -o tsv

The fix is to register the namespace and wait for it to reach Registered before the next apply. Registration can take a few minutes for large namespaces such as Microsoft.ContainerService; polling with --wait avoids a second failed run.

# CLI: register everything this stack needs, then confirm
for ns in Microsoft.Storage Microsoft.Web Microsoft.ContainerService \
          Microsoft.OperationalInsights Microsoft.ManagedIdentity; do
  az provider register --namespace "$ns" --wait
done
az provider list --query "[?registrationState!='Registered'].[namespace,registrationState]" -o tsv

The bridged pulumi-azure provider tries to help here: by default it registers a broad list of namespaces at provider startup, which is convenient on a personal subscription and unacceptable in a governed one, because it needs subscription-level write permission on every deploy. Set skip_provider_registration=True and register namespaces out of band.

# registration.py — take registration out of the deploy path
# CLI: pulumi up
import pulumi
import pulumi_azure as azure_classic

cfg = pulumi.Config()

bridged = azure_classic.Provider(
    "azure-classic",
    features=azure_classic.ProviderFeaturesArgs(),
    subscription_id=cfg.require("subscriptionId"),
    skip_provider_registration=True,
)
# Provider note: with this flag the deploy principal needs no subscription-level
# register/action right; a platform pipeline registers namespaces separately.

pulumi-azure-native never registers namespaces on your behalf, which is why the raw MissingSubscriptionRegistration reaches you. Treat the registered-namespace list as part of the landing zone: a subscription is not ready for a stack until every namespace that stack writes to is Registered. The Azure Functions guide hits this first, because Microsoft.Web is frequently absent from subscriptions that have only ever run virtual machines.

Resource Naming Rules and Pulumi Auto-Naming

Pulumi gives every resource a logical name and, unless you say otherwise, derives the physical name by appending a hyphen and seven random hex characters — eight characters in total. That default is excellent for ephemeral review stacks and hostile to Azure, because Azure's name validation is unusually strict and varies per resource type. A storage account is 3–24 characters of lowercase letters and digits only; a key vault is 3–24 characters with hyphens allowed; an AKS managed cluster allows 63; a resource group allows 90.

ARM name length ceilings ARM name length ceilings: Storage account, Key vault, Function app, AKS managed cluster, Resource group. Storage account 24 chars Key vault 24 chars Function app 60 chars AKS managed cluster 63 chars Resource group 90 chars
Auto-naming appends 8 characters, so the ceiling decides how long your logical name may be.

Two failure modes follow. The first is a name too long: a logical name of 20 characters plus the eight-character suffix exceeds the 24-character storage limit and the apply dies during validation rather than during creation.

# CLI: what a name-validation failure looks like
# error: Code="AccountNameInvalid" Message="platformassets-a1b2c3d is not a valid
#   storage account name. Storage account name must be between 3 and 24 characters
#   in length and use numbers and lower-case letters only."

The second is a name that is globally unique across all of Azure. Storage account names, key vault names, and the default hostname of a Function App share one global namespace, so a perfectly valid name can still be taken by another tenant:

# CLI: the collision is not a permissions problem
# error: Code="StorageAccountAlreadyTaken" Message="The storage account named
#   platformassets is already taken."
az storage account check-name --name platformassets --query '[nameAvailable,reason]' -o tsv

The pragmatic pattern is a small typed helper that builds deterministic names from a project, a stack, and a role, truncates to the resource type's ceiling, and strips characters the type disallows. Deterministic names survive a stack replacement, which random suffixes do not — and anything that DNS, a policy exemption, or a firewall rule refers to by name must survive.

# naming.py — deterministic, validated Azure names
# CLI: pulumi up
from dataclasses import dataclass
import hashlib
import re
import pulumi

@dataclass(frozen=True)
class NameRule:
    max_length: int
    allowed: str          # regex character class of permitted characters
    lower_only: bool

RULES: dict[str, NameRule] = {
    "storageaccount": NameRule(24, r"[^a-z0-9]", True),
    "keyvault": NameRule(24, r"[^A-Za-z0-9-]", False),
    "managedcluster": NameRule(63, r"[^A-Za-z0-9-]", False),
    "resourcegroup": NameRule(90, r"[^A-Za-z0-9._()-]", False),
}

def azure_name(kind: str, role: str) -> str:
    """Deterministic per-stack physical name that satisfies the type's rules."""
    rule = RULES[kind]
    raw = f"{pulumi.get_project()}{pulumi.get_stack()}{role}"
    if rule.lower_only:
        raw = raw.lower()
    cleaned = re.sub(rule.allowed, "", raw)
    if len(cleaned) <= rule.max_length:
        return cleaned
    digest = hashlib.sha256(raw.encode()).hexdigest()[:6]
    return cleaned[: rule.max_length - 6] + digest
# State implication: because the name is a pure function of project, stack and role,
# a destroy-and-recreate lands on the same physical name — no orphaned DNS entries.

Feed that helper into the explicit name argument for each resource type: account_name on a storage account, resource_group_name on a resource group, resource_name on an AKS managed cluster. The Azure storage account guide works through the 24-character case in full, including how a naming scheme interacts with the global uniqueness check.

One inherited quirk is worth knowing before you hit it. Some ARM types carry a property literally called resourceName, which collides with Pulumi's own first positional argument, so the generated SDK exposes it with a trailing underscore — resource_name_. UserAssignedIdentity is the type you will meet first. It is not a typo in the SDK.

Identity: CLI Login, Service Principal, or Managed Identity

Azure has four credential shapes and the provider reads them in a fixed order: explicit provider arguments, then ARM_* environment variables, then a managed identity if use_msi is set, then the Azure CLI's cached token. Which one is correct is decided by where the deploy runs, not by preference.

Choosing an Azure identity Choosing an Azure identity: choose among 4 options. Where does this deploy run? laptop Azure CLI login GitHub OIDC federatedcredential in Azure Managed identity legacy CI Client secret
The execution context, not preference, decides which credential the provider should read.

An interactive az login is right on a laptop and wrong everywhere else, because the token belongs to a human and expires. A client secret is the lowest common denominator: it works from any CI system, and it is a long-lived credential you now have to rotate and store. Workload-identity federation removes the secret entirely — the CI system presents a short-lived OIDC token, Entra ID exchanges it for an access token, and nothing durable is stored. A managed identity is the same idea for compute running inside Azure: the platform issues the token and there is no credential to leak. Authenticating Pulumi to Azure with service principals sets up the app registration, the federated credential, and the role assignments step by step.

# provider_identity.py — one explicit provider per credential shape
# CLI: pulumi up
import pulumi
import pulumi_azure_native as azure_native

cfg = pulumi.Config("azure-native")
subscription_id: str = cfg.require("subscriptionId")
location: str = cfg.require("location")

# Federated (OIDC) credentials: no secret is stored anywhere.
ci_provider = azure_native.Provider(
    "azure-ci",
    subscription_id=subscription_id,
    tenant_id=cfg.require("tenantId"),
    client_id=cfg.require("clientId"),
    use_oidc=True,
    location=location,
)
# Provider note: the provider reads the CI runner's OIDC request token from the
# environment; there is no client_secret to rotate.

# A deploy agent running on an Azure VM or container uses the platform's identity.
in_azure_provider = azure_native.Provider(
    "azure-msi",
    subscription_id=subscription_id,
    use_msi=True,
    location=location,
)

The identities your stack creates are a separate question from the identity that runs the deploy. Application workloads should get user-assigned managed identities, granted narrow data-plane roles, so that no application ever holds a connection string. Role assignments have two properties that surprise people: the name must be a GUID, and most fields are immutable after creation.

# workload_identity.py — a user-assigned identity with a scoped data-plane role
# CLI: pulumi up
import uuid
import pulumi
import pulumi_azure_native as azure_native

STORAGE_BLOB_DATA_READER = "2a2b9908-6ea1-4ae2-8e65-a410df84e7d1"

identity = azure_native.managedidentity.UserAssignedIdentity(
    "app-identity",
    resource_group_name=rg.name,
    resource_name_="app-identity",   # Provider note: trailing underscore avoids the SDK collision
    location=location,
)

assignment = azure_native.authorization.RoleAssignment(
    "app-blob-reader",
    scope=account.id,
    principal_id=identity.principal_id,
    principal_type="ServicePrincipal",
    role_definition_id=pulumi.Output.concat(
        "/subscriptions/", subscription_id,
        "/providers/Microsoft.Authorization/roleDefinitions/", STORAGE_BLOB_DATA_READER,
    ),
    role_assignment_name=str(uuid.uuid5(uuid.NAMESPACE_URL, "app-identity/blob-reader")),
)
# State implication: role_assignment_name must be a GUID and is part of the resource id.
# Deriving it from a stable string keeps the assignment from being replaced on every up.

Setting principal_type="ServicePrincipal" is not cosmetic. Without it ARM looks the principal up in Entra ID to infer its type, and a principal created seconds earlier in the same deploy may not have replicated yet, producing PrincipalNotFound. Declaring the type skips the lookup.

Eventual Consistency and ARM's Async Operations

Almost every ARM write is asynchronous. The provider sends a PUT, ARM answers 201 Created or 202 Accepted with an Azure-AsyncOperation or Location header, and the real work continues behind that URL. The Pulumi provider polls it until the operation reports Succeeded, Failed, or Canceled, and only then does the resource move to created in state.

ARM async operation polling ARM async operation polling: PUT returns 201 → Async-Operation URL → Provider polls → InProgress → Succeeded or Failed → repeat. PUT returns 201 Async-OperationURL Provider polls InProgress Succeeded orFailed
Pulumi's create timeout covers the whole polling loop, not just the initial request.

Two consequences follow. First, Pulumi's customTimeouts govern the whole polling loop, not the initial request — a 10-minute default is generous for a storage account and far too short for a managed Kubernetes control plane, an ExpressRoute circuit, or a Front Door profile. Second, when a timeout fires, the ARM operation keeps running. Pulumi records the resource as failed while Azure goes on to create it, and the next pulumi up tries to create an object that already exists.

# timeouts.py — timeouts sized to the real operation, not the default
# CLI: pulumi up
import pulumi
import pulumi_azure_native as azure_native

managed_k8s = azure_native.containerservice.ManagedCluster(
    "platform-aks",
    resource_group_name=rg.name,
    resource_name_="platform-aks",
    location=location,
    dns_prefix="platform",
    identity=azure_native.containerservice.ManagedClusterIdentityArgs(type="SystemAssigned"),
    agent_pool_profiles=[azure_native.containerservice.ManagedClusterAgentPoolProfileArgs(
        name="system", count=3, vm_size="Standard_D4s_v5", mode="System",
    )],
    opts=pulumi.ResourceOptions(
        custom_timeouts=pulumi.CustomTimeouts(create="45m", update="45m", delete="30m"),
    ),
)
# Provider note: a timeout aborts Pulumi's polling, not the ARM operation itself.
# State implication: after a timeout, run `pulumi refresh` before the next up so the
# engine adopts whatever Azure finished creating in the background.

The eventual-consistency problem is different from the async one and needs a different fix. ARM's read path is served from replicas that can trail the write path by seconds, and Entra ID's directory replication can trail by longer. A resource that was just created can return 404 on the next read; a service principal created in step one can be invisible to a role assignment in step two. The correct fix is almost never a sleep — it is to make the ordering explicit so the provider does the retrying.

Pass real Output values rather than reconstructing strings, so Pulumi's dependency graph knows the ordering. Where there is no data dependency but there is a real ordering requirement — a role assignment that must exist before a workload starts and reads a secret — use depends_on. Where a downstream resource genuinely needs a settled directory, pulumi.Output.all(...).apply(...) on the upstream ids keeps the sequencing inside the graph rather than in wall-clock time.

# ordering.py — express ordering as dependencies, not as sleeps
# CLI: pulumi up
import pulumi
import pulumi_azure_native as azure_native

secret = azure_native.keyvault.Secret(
    "db-password",
    resource_group_name=rg.name,
    vault_name=vault.name,          # Output, so the vault is a real dependency
    secret_name="db-password",
    properties=azure_native.keyvault.SecretPropertiesArgs(value=db_password),
    opts=pulumi.ResourceOptions(depends_on=[vault_access_assignment]),
)
# Provider note: without depends_on the secret write can beat the RBAC assignment's
# propagation and fail with Forbidden even though the assignment exists in state.

Tags, Policy, and Scope Inheritance

Azure organises everything into a scope chain: management group, subscription, resource group, resource. Policy assignments cascade down that chain, and a policy assigned at a management group applies to subscriptions created afterwards. Tags do not cascade. A resource group's tags are not inherited by the resources inside it unless a policy with a modify effect copies them, and even then the copy applies at write time and needs a remediation task for resources that already exist.

Scope inheritance in Azure Scope inheritance in Azure: layered from Management group down to Resource. Management group policy assignments cascade down Subscription policy, budgets, RP registration Resource group tag inheritance source, RBAC scope Resource effective tags and denied properties
Policy flows down the scope chain; tags do not, unless a policy copies them.

pulumi-azure-native has no default_tags argument — that concept belongs to the AWS provider. The equivalent on Azure is a stack transformation that merges a base tag set into every resource that supports tags. Not every type does: role assignments, subnets, and most child resources reject a tags property outright, so the transformation must skip them rather than blanket-apply.

# tagging.py — one base tag set applied through a stack transformation
# CLI: pulumi up
from typing import Optional
import pulumi

BASE_TAGS: dict[str, str] = {
    "owner": "platform",
    "env": pulumi.get_stack(),
    "managed-by": "pulumi",
    "cost-centre": "cc-4412",
}

# ARM types that reject a tags property; extend as the stack grows.
UNTAGGABLE: frozenset[str] = frozenset({
    "azure-native:authorization:RoleAssignment",
    "azure-native:network:Subnet",
    "azure-native:keyvault:Secret",
    "azure-native:storage:BlobContainer",
})

def apply_base_tags(
    args: pulumi.ResourceTransformationArgs,
) -> Optional[pulumi.ResourceTransformationResult]:
    if args.type_ in UNTAGGABLE or not args.type_.startswith("azure-native:"):
        return None
    merged = {**BASE_TAGS, **(args.props.get("tags") or {})}
    return pulumi.ResourceTransformationResult(
        props={**args.props, "tags": merged}, opts=args.opts,
    )

pulumi.runtime.register_stack_transformation(apply_base_tags)
# State implication: the transformation runs before the resource is registered, so the
# merged tags appear in the preview diff rather than showing up as drift afterwards.

Policy is the other half. A deny-effect assignment rejects the write at ARM, and Pulumi reports it verbatim — the message includes the assignment name, which is what you need to find the rule:

# CLI: a deny policy failure names the assignment that blocked the write
# error: Code="RequestDisallowedByPolicy" Message="Resource 'platformassets' was
#   disallowed by policy. Policy identifiers: '[{"policyAssignment":{"name":
#   "require-owner-tag"}}]'."
az policy assignment list --scope "/subscriptions/$SUB_ID" --query '[].[name,displayName]' -o tsv

Assigning policy from the same program that deploys the resources gives you one review surface for both, and pairs naturally with preview-time checks — see Pulumi policy as code for the complementary approach that fails the preview before ARM ever sees the request.

# policy.py — assign a built-in policy at subscription scope
# CLI: pulumi up
import pulumi_azure_native as azure_native

require_tag = azure_native.authorization.PolicyAssignment(
    "require-owner-tag",
    policy_assignment_name="require-owner-tag",
    scope=f"/subscriptions/{subscription_id}",
    policy_definition_id=(
        "/providers/Microsoft.Authorization/policyDefinitions/"
        "1e30110a-5ceb-460c-a204-c1c3969c6d62"     # Require a tag on resources
    ),
    parameters={"tagName": azure_native.authorization.ParameterValuesValueArgs(value="owner")},
    enforcement_mode="Default",
)
# Provider note: enforcement_mode="DoNotEnforce" evaluates and reports without denying,
# which is how a new policy should be rolled out to an existing subscription.

Region and Zone Availability

location is a per-resource property, not a provider-wide guarantee, and the set of SKUs and features available differs by region far more on Azure than on AWS. Some regions expose three availability zones; others expose none, and passing a zones argument in one of those regions is rejected rather than ignored. Zone-redundant storage, premium zone-redundant managed disks, and the newest virtual-machine families are all region-conditional.

Region capability differences Region capability differences: comparison across Zonal region, Single-zone region. Concern Zonal region Single-zone region Availability zones 3 zones exposed zones argument rejected Zone-redundant storage ZRS and GZRS LRS and GRS only Managed disk SKU Premium ZRS Premium LRS only New GPU families available first often unavailable
Region choice silently changes which SKUs and zone arguments the ARM API will accept.

The failure is a capacity error at apply time, not a validation error at preview time, because ARM only knows whether a SKU is available in a zone when it tries to allocate:

# CLI: check SKU and zone availability before committing to a region
az vm list-skus --location westeurope --size Standard_D4s_v5 \
  --query '[0].locationInfo[0].zones' -o tsv
az provider show --namespace Microsoft.Storage \
  --query "resourceTypes[?resourceType=='storageAccounts'].locations[]" -o tsv
# error: Code="SkuNotAvailable" Message="The requested VM size for resource
#   '/subscriptions/.../virtualMachines/app-01' is currently not available in
#   location 'westeurope' zones '2' for subscription ..."

Two practices keep this manageable. Put the region in stack config and never hard-code it in a resource, so a region change is a config edit rather than a search-and-replace. And treat zone counts as data: a dict[str, int] of region to available zones, read at construction time, lets the same program deploy a three-zone node pool in a zonal region and a single-zone one elsewhere without branching everywhere. The AKS guide applies exactly this to node-pool placement, where a zone list that a region cannot honour fails the whole managed control plane rather than one node.

A related trap is the pairing of location values with their normalised form. ARM accepts West Europe, westeurope, and West europe, then returns westeurope. If the program sends the display form, every preview shows a diff on location and, for types where location forces replacement, proposes to replace the resource. Always store the compact lowercase form in config.

Step-by-Step: Configuring the Provider

Set the subscription explicitly rather than relying on whichever one the CLI last selected — an ambient default is the most common cause of resources appearing in the wrong place.

Configuration order Configuration order: select subscription then set stack config then create resource group then declare resources selectsubscription az account set stack config subscription + region create resourcegroup parent scope declare resources named group
Subscription and region are settled in stack config before any resource is declared.
# CLI: pin the subscription and location in stack config, not in ambient CLI state
pulumi config set azure-native:subscriptionId "$(az account show --query id -o tsv)"
pulumi config set azure-native:location westeurope
pulumi config set azure-native:tenantId "$(az account show --query tenantId -o tsv)"

Those three keys land in Pulumi.<stack>.yaml, which means the subscription a stack targets is reviewable in a pull request rather than implied by an operator's shell:

# Pulumi.prod.yaml — the target subscription is code, not shell state
# CLI: pulumi config
config:
  azure-native:subscriptionId: 00000000-0000-0000-0000-000000000000
  azure-native:location: westeurope
  azure-native:tenantId: 11111111-1111-1111-1111-111111111111

Then declare a resource group and a first resource. Every subsequent resource in the stack names this group.

# __main__.py — resource group plus an explicit provider instance
# CLI: pulumi up
import pulumi
import pulumi_azure_native as azure

cfg = pulumi.Config("azure-native")
location: str = cfg.require("location")

rg = azure.resources.ResourceGroup(
    "platform-rg",
    resource_group_name="platform-prod",   # Provider note: explicit name, no random suffix
    location=location,
    tags={"owner": "platform", "env": "prod"},
)

pulumi.export("resourceGroup", rg.name)
# State implication: the resource group is the parent of every resource below it;
# deleting it from the program deletes everything inside it on the next apply.

Provider note: resource_group_name fixes the Azure-side name. Omit it and Pulumi appends a random suffix, which is fine for ephemeral stacks and confusing for shared ones.

For stacks that span subscriptions — a shared network subscription and a workload subscription, say — instantiate a second azure_native.Provider and pass it explicitly through pulumi.ResourceOptions(provider=...). An explicit provider instance is also the only way to have two different subscriptions in one stack, because the azure-native:subscriptionId config key configures the default provider only.

Verification

Confirm the stack landed in the intended subscription and region — not merely that the apply succeeded.

# CLI: the group exists in the expected subscription with the expected region
az group show --name platform-prod --query '[name, location, id]' -o tsv
pulumi stack output resourceGroup

The id in the output begins with /subscriptions/<id>/; check that it matches the subscription you configured. A successful deploy into the wrong subscription looks identical to a correct one until the bill arrives.

Three more checks are worth running once per subscription, because each catches a class of failure that only shows up later:

# CLI: subscription-level readiness checks
az provider list --query "[?registrationState!='Registered'].namespace" -o tsv
az role assignment list --assignee "$CLIENT_ID" --all \
  --query '[].[roleDefinitionName,scope]' -o tsv
az resource list --resource-group platform-prod \
  --query "[?tags.owner==null].[name,type]" -o tsv

The first lists namespaces that will fail on first use. The second shows exactly what the deploy principal can do and where — the answer is frequently broader than intended. The third finds resources the tagging transformation missed, which is how you discover the UNTAGGABLE set needs an entry.

Finally, pulumi preview --diff immediately after a successful pulumi up should report no changes. If it reports a diff on location, tags, or a SKU name, the program is sending a value that ARM normalises, and that diff will become a replacement the day one of those properties is marked force-new.

Troubleshooting

MissingSubscriptionRegistration — full text: The subscription is not registered to use namespace 'Microsoft.Web'. Cause: the resource provider has never been registered on this subscription. Fix: az provider register --namespace Microsoft.Web --wait, then re-run. If the deploy principal lacks register rights, ask the subscription owner; do not work around it by widening the principal's role.

AuthorizationFailed — full text: The client '<oid>' with object id '<oid>' does not have authorization to perform action 'Microsoft.Resources/subscriptions/resourcegroups/write' over scope '/subscriptions/<id>/resourcegroups/platform-prod' or the scope is invalid. Read the action and the scope literally — they name exactly what to grant. Fix: az role assignment list --assignee <client-id> --all and grant at the right scope.

SubscriptionNotFound — the configured subscriptionId is not visible to the principal. Cause: the service principal lives in a different tenant, or the subscription was moved. Fix: confirm az account show --query tenantId matches the principal's tenant.

InvalidAuthenticationTokenTenant — the token was issued by one tenant and presented to a subscription in another. Cause: ARM_TENANT_ID left over from a different environment, or a guest account defaulting to its home tenant. Fix: set tenantId explicitly in stack config rather than inheriting it.

ResourceGroupNotFound on a resource that Pulumi just created — Azure's API is eventually consistent across regions. Fix: let Pulumi's dependency graph order the work by passing rg.name directly rather than a hard-coded string, so the group is a real dependency.

PrincipalNotFound — full text: Principal <oid> does not exist in the directory <tenant-id>. Cause: a role assignment referencing a service principal or managed identity created moments earlier, before Entra ID replication caught up. Fix: set principal_type="ServicePrincipal" on the assignment so ARM skips the directory lookup.

AccountNameInvalid or StorageAccountAlreadyTaken — the physical name broke a per-type rule or collided in a global namespace. Fix: use an explicit, deterministic name from a helper that enforces the type's length and character rules, and check availability with az storage account check-name before the apply.

RequestDisallowedByPolicy — a deny-effect policy rejected the write; the message contains the offending assignment's name. Fix: satisfy the policy (usually a missing tag or a disallowed SKU or region) rather than exempting the resource. Roll new policies out with enforcement_mode="DoNotEnforce" first.

SkuNotAvailable — the SKU or zone combination is not offered in that region for this subscription. Fix: query az vm list-skus --location <region> and either change the SKU, drop the zones argument, or move the resource to a region that offers it.

ScopeLocked — full text: The scope '/subscriptions/<id>/resourceGroups/platform-prod' cannot perform delete operation because following scope(s) are locked. Cause: a CanNotDelete management lock, often applied by a governance policy. Fix: remove the lock deliberately, or exclude the resource from the destroy.

context deadline exceeded during a create — Pulumi stopped polling before ARM finished. The ARM operation is still running. Fix: raise custom_timeouts for that resource type, then pulumi refresh so the engine adopts whatever Azure completed in the background before the next up.

Resource replaced on every preview — a property Azure normalises (casing on location, for instance) differs from what the program sends. Fix: read the value back from az resource show and use Azure's normalised form, applying the same drift discipline as detecting and remediating state drift.

FAQ

Which Azure provider should a new project use?

pulumi-azure-native. It covers the full ARM API and its property names match Azure's documentation, which makes errors searchable. Use the bridged pulumi-azure provider only when porting existing Terraform.

Can I use both providers in one stack?

Yes, but never for the same resource. Two providers managing one Azure object will fight, each reverting the other's changes on alternate applies.

Why does my first deploy in a brand-new subscription fail?

Almost always MissingSubscriptionRegistration: the resource provider for that namespace has never been registered on the subscription. Register it with az provider register --namespace <namespace> --wait and re-run. Fresh subscriptions register only a common core set automatically.

How do I avoid random suffixes on Azure resource names?

Pass the explicit name argument (resource_group_name, account_name, and so on). Pulumi's auto-naming is helpful for ephemeral stacks and unhelpful for shared infrastructure with DNS or policy dependencies.

Does the resource group have to be in the same region as its resources?

No. The resource group's location only stores group metadata; resources inside it may sit in other regions. Keeping them aligned is a convention, not a constraint.

Why does a role assignment fail right after the identity is created?

Entra ID replicates separately from ARM, so a principal created seconds earlier may not be visible to the assignment yet — the error is PrincipalNotFound. Set principal_type="ServicePrincipal" so ARM does not look the principal up, and let the dependency graph rather than a sleep control the ordering.

How long should a create timeout be for an AKS control plane?

Longer than the 10-minute default — 45 minutes is a safe ceiling for a managed Kubernetes control plane with several node pools. Remember that a Pulumi timeout stops the polling, not the ARM operation, so run pulumi refresh before retrying.