Provision Azure Storage Accounts with Pulumi Python

An Azure storage account is one resource wearing four hats — blobs, files, queues, and tables — and its security defaults are weaker than most teams assume. This guide, part of Azure provider configuration under Pulumi patterns and provider management, provisions a locked-down account with a private blob container in typed Python.

Context

Storage accounts carry constraints that no other Azure resource does: the name must be globally unique across all of Azure, lowercase, and 3–24 characters with no hyphens. They also default to allowing shared-key access and public network access, which is the origin of most accidental exposure. Setting the security posture in code — rather than fixing it after a scan flags it, as described in scanning Python IaC with Checkov — keeps the account correct from creation.

The pulumi_azure_native provider matters here more than on most resources. It is generated directly from the Azure Resource Manager specifications, so storage.StorageAccount maps one-to-one onto a PUT against Microsoft.Storage/storageAccounts at a pinned API version. That has a practical consequence: an argument exists in the Python class only if it exists in the API version the provider version was generated against. When a property you expect is missing, the fix is to move the provider version, not to look for a different class. The same generation step is why the arguments are ARM's names in snake case (allow_blob_public_access, minimum_tls_version) rather than the friendlier names the Azure portal shows.

There are two planes to secure and they are configured in different places. The control plane is ARM: who may read the account's keys, change its SKU, or delete it, governed by Azure RBAC on the resource. The data plane is the blob, file, queue, and table endpoints: who may read an object. Disabling shared-key access does not restrict the control plane at all — a Contributor can still call listKeys unless you deny it — and enabling a private endpoint does not restrict the data plane's authorization, only its reachability. A hardened account needs a deliberate answer on both.

Storage account surface Storage account surface: Storage account with 4 facets. Storage account Blob containers + objects File SMB shares Queue messages Table key-value rows
One account exposes four independent services; each has its own access surface.

Prerequisites

  • pulumi-azure-native>=2.0 and Contributor on the target resource group
  • A globally unique account name: 3–24 characters, lowercase letters and digits only
  • A decision on replication (LRS, ZRS, GRS) — it affects both durability and cost
  • An authenticated Azure session. For local work az login is enough; in CI use a workload identity or a service principal, as set out in authenticating Pulumi to Azure with service principals
  • User Access Administrator or Owner on the resource group if you intend to create the role assignment in step four — Contributor alone cannot grant roles
# CLI: check the name is free before the apply spends time failing
az storage account check-name --name platformprodstore --query 'nameAvailable'

Set the subscription and location on the stack rather than hard-coding them, so the same program can target a sandbox subscription without an edit:

# CLI: pin the provider's subscription and default location for this stack
pulumi config set azure-native:subscriptionId 00000000-1111-2222-3333-444444444444
pulumi config set azure-native:location westeurope

Azure will reject a bad name late and slowly, so validate it early. az storage account check-name returns {"nameAvailable": false, "reason": "AlreadyExists"} for a taken name and "AccountNameInvalid" for a malformed one — worth distinguishing in a CI pre-check, because the first is someone else's account and the second is your bug.

Implementation

Step 1 — declare the account with the insecure defaults turned off. Each of these three flags closes a common exposure path.

Replication options Replication options: comparison across Copies, Survives. SKU Copies Survives Standard_LRS 3 in one datacenter disk or rack loss Standard_ZRS 3 across zones one zone offline Standard_GRS 6 across regions region loss
Replication is a cost-versus-blast-radius decision made at creation.
# storage.py — a storage account with shared-key and public access disabled
# CLI: pulumi up
from typing import Final
import pulumi
import pulumi_azure_native.resources as resources
import pulumi_azure_native.storage as storage

cfg: Final[pulumi.Config] = pulumi.Config()
env: Final[str] = cfg.require("env")          # "prod" | "staging" | "dev"

rg = resources.ResourceGroup(
    "platform",
    resource_group_name=f"rg-platform-{env}",
)

account = storage.StorageAccount(
    "platform",
    account_name="platformprodstore",        # globally unique, no hyphens allowed
    resource_group_name=rg.name,
    sku=storage.SkuArgs(name="Standard_ZRS"),
    kind=storage.Kind.STORAGE_V2,
    minimum_tls_version=storage.MinimumTlsVersion.TLS1_2,
    allow_blob_public_access=False,          # blocks anonymous container reads
    allow_shared_key_access=False,           # forces Entra ID auth, not account keys
    enable_https_traffic_only=True,
    tags={"owner": "platform", "env": "prod"},
)
# Provider note: allow_shared_key_access=False breaks tools that use connection strings.
# State implication: the account name is immutable; changing it replaces the account
# and every blob in it.

Three arguments there deserve more than a comment. kind=STORAGE_V2 is the only kind worth choosing for new work — StorageV1 and BlobStorage are legacy kinds that silently exclude features such as lifecycle management, and the kind is immutable in the sense that upgrading is a one-way ARM operation Pulumi will not perform for you. minimum_tls_version is enforced by the service on every data-plane request, not merely advertised, so setting it to TLS1_2 immediately breaks any client pinned to an older stack; the failure is a TLS handshake error at the socket, not an Azure error code, which makes it unusually annoying to diagnose from logs. And enable_https_traffic_only=True is redundant on accounts created recently — it defaults to true — but declaring it explicitly means Pulumi will correct it if someone flips it in the portal, which is the entire point of managing the account in code.

Note what is not in the resource: keys, connection strings, and endpoints are all outputs, never inputs. account.primary_endpoints.blob is an Output[str] resolved after creation, so anything that consumes it must go through apply or be exported. Reaching for listStorageAccountKeys to build a connection string is the reflex to resist here — with allow_shared_key_access=False those keys exist but are refused by the service.

Step 2 — add a private container. Containers are separate resources, and public_access defaults to none only when set explicitly.

# container.py — a private blob container inside the account
container = storage.BlobContainer(
    "artifacts",
    container_name="artifacts",
    account_name=account.name,
    resource_group_name=rg.name,
    public_access=storage.PublicAccess.NONE,
)

pulumi.export("blobEndpoint", account.primary_endpoints.blob)
pulumi.export("accountId", account.id)
# State implication: deleting the container from the program deletes its blobs.

BlobContainer is a child of the account by reference, not by nesting, so Pulumi infers the dependency from account_name=account.name. If you pass a literal string instead, Pulumi may attempt the container before the account exists and the apply fails with ParentResourceNotFound: Can not perform requested operation on nested resource. Parent resource 'platformprodstore' not found. Always thread the output.

Step 3 — close the network path. The three security flags in step one govern authorization; they say nothing about who can reach the endpoint. Until you set a network rule set, the blob endpoint answers requests from the entire internet and merely rejects them at the auth layer.

Choosing the network path Choosing the network path: choose among 3 options. How do clients reach theaccount? public Public endpoint withIP allow list vnet Service endpoint ona subnet private Private endpoint andprivate DNS zone
The network_rule_set default action decides which of these three shapes you are actually running.
# storage_network.py — deny by default, allow one subnet and the CI egress IP
# CLI: pulumi up --diff
import pulumi_azure_native.storage as storage

network_rules = storage.NetworkRuleSetArgs(
    default_action=storage.DefaultAction.DENY,
    bypass=storage.Bypass.AZURE_SERVICES,     # keeps Backup and Monitor working
    ip_rules=[
        storage.IPRuleArgs(i_p_address_or_range="203.0.113.7"),
    ],
    virtual_network_rules=[
        storage.VirtualNetworkRuleArgs(virtual_network_resource_id=app_subnet.id),
    ],
)
# Provider note: the argument really is spelled i_p_address_or_range — the ARM
# property is "iPAddressOrRange" and the snake-case conversion splits the "IP".
# State implication: setting default_action=DENY takes effect on the next apply
# and will lock out your own workstation unless its egress IP is in ip_rules.

Pass network_rule_set=network_rules into the StorageAccount from step one. The bypass value is the part teams get wrong: with DefaultAction.DENY and no bypass, Azure Monitor diagnostic settings, Azure Backup, and the portal's own blob browser all lose access, and the symptom is a diagnostic setting that silently stops delivering logs rather than an error.

Step 4 — grant data-plane access with RBAC. Because shared-key access is off, no principal can read a blob until it holds a data-plane role. Control-plane Contributor is not sufficient and never becomes sufficient.

Entra ID data-plane write Entra ID data-plane write: Pulumi program → Entra ID → Storage account. Pulumi program Entra ID Storage account request token bearer token PUT blob check RBAC 201 Created
With shared-key access disabled, every data-plane call carries a bearer token evaluated against role assignments.
# rbac.py — scope the data-plane role to the container, not the account
# CLI: pulumi up
import pulumi
import pulumi_azure_native.authorization as authorization

BLOB_DATA_CONTRIBUTOR: str = (
    "/providers/Microsoft.Authorization/roleDefinitions/"
    "ba92f5b4-2d11-453d-a403-e96b0029c9fe"
)

authorization.RoleAssignment(
    "artifacts-writer",
    principal_id=app_identity.principal_id,
    principal_type=authorization.PrincipalType.SERVICE_PRINCIPAL,
    role_definition_id=BLOB_DATA_CONTRIBUTOR,
    scope=pulumi.Output.concat(
        account.id, "/blobServices/default/containers/", container.name
    ),
)
# Provider note: principal_type must be set explicitly for a freshly created
# identity, otherwise the assignment fails with PrincipalNotFound because
# Entra ID replication has not caught up.
# State implication: the assignment is a separate ARM resource — destroying the
# stack removes the grant but leaves any blobs already written.

Scoping to the container rather than the account is the whole value of doing this in code. A portal operator will nearly always grant at account scope because that is the default the blade offers; a program can just as easily concatenate the container path and keep the grant narrow. The same reasoning, applied across providers, is set out in enforcing IAM least privilege in Python IaC.

Verification

Confirm the account rejects the access paths you disabled, rather than only confirming it exists.

# CLI: the three security flags should all report the hardened value
az storage account show --name platformprodstore \
  --query '[allowBlobPublicAccess, allowSharedKeyAccess, minimumTlsVersion]' -o tsv
# Expect: False  False  TLS1_2

# An anonymous read must fail with 404/PublicAccessNotPermitted
curl -s -o /dev/null -w '%{http_code}\n' \
  "$(pulumi stack output blobEndpoint)artifacts/probe.txt"

Then prove the positive case, which is the one that actually breaks in production. A write through Entra ID auth must succeed from an allowed network and fail from a blocked one:

# CLI: write through Entra ID auth — must succeed from an allowed source IP
az storage blob upload --auth-mode login \
  --account-name platformprodstore --container-name artifacts \
  --name probe.txt --file ./probe.txt --overwrite

# CLI: confirm the network rule set is actually deny-by-default
az storage account show --name platformprodstore \
  --query 'networkRuleSet.{default:defaultAction,bypass:bypass,ips:ipRules[].value}'

Two failure strings are worth recognising immediately. AuthorizationPermissionMismatch means the network let you through and RBAC refused you — the role assignment is missing, scoped too narrowly, or has not replicated yet (allow up to five minutes). AuthorizationFailure with a message about the request not being authorized to perform this operation using this permission means the network rule set rejected the source address before authorization ran at all. Confusing the two costs an afternoon; they point at opposite fixes.

Finally, check the assignment from the control plane so you are not relying on a successful upload as your only evidence:

# CLI: list data-plane grants at container scope
az role assignment list --scope \
  "$(pulumi stack output accountId)/blobServices/default/containers/artifacts" \
  --query '[].{role:roleDefinitionName, principal:principalId}' -o table

Gotchas & Edge Cases

Disabling shared-key access breaks connection strings. Any tool or SDK call that authenticates with an account key stops working, including az storage blob upload without --auth-mode login. Migrate consumers to Entra ID auth before flipping the flag in production.

The name is global and permanent. Two teams can collide on companystore, and renaming replaces the account. Include the environment in the name and check availability in CI before the apply.

Soft delete changes deletion semantics. With blob soft delete enabled, a removed container lingers and its name stays reserved, so an immediate re-create fails with ContainerBeingDeleted. Wait out the retention window or use a new name.

ZRS is not available everywhere. Zone-redundant storage requires a region with availability zones, and a mismatched pair fails the create with SkuNotAvailable: The requested SKU Standard_ZRS is not available in location <region>. Read the SKU from stack config rather than hard-coding it, so a dev stack in a single-zone region can fall back to Standard_LRS without a code change.

Changing the replication SKU is sometimes an in-place update and sometimes not. LRS to GRS is a live conversion the service performs; LRS to ZRS is not, and Pulumi surfaces that as a replacement in the preview. Read the preview's replace markers before approving a SKU change on an account holding data — a replacement destroys every blob.

Role assignments race their principals. Creating a managed identity and a RoleAssignment in the same pulumi up frequently fails the first time with PrincipalNotFound: Principal <guid> does not exist in the directory. This is Entra ID replication lag, not a bug in your program. Setting principal_type explicitly avoids the ARM-side existence check in most cases; where it still races, a pulumi.ResourceOptions(depends_on=[app_identity]) plus a retry on the pipeline step is the pragmatic fix.

account.id is not the storage account name. It is the full ARM resource id (/subscriptions/…/providers/Microsoft.Storage/storageAccounts/platformprodstore). Passing it where a name is expected produces InvalidResourceName, which reads like a naming-rule violation and is actually a wrong-field error.

Operational Notes

Lifecycle rules belong in the program, not in a runbook. A ManagementPolicy resource attached to the account expresses tiering and expiry declaratively — move blobs to cool after 30 days, delete after 365 — and it is by far the largest cost lever on a storage account that accumulates artifacts. Left to a manual portal change, it is the first thing to disappear when the account is recreated in a new environment.

Diagnostic settings are a separate resource and a common blind spot. Storage does not log data-plane reads by default. If you need an audit trail of who read which blob, add a DiagnosticSetting targeting the blobServices/default sub-resource with the StorageRead and StorageWrite categories, pointed at a Log Analytics workspace. Enable it at the same time as the account, because backfilling access logs is impossible.

Plan for key rotation even with shared-key access disabled. The keys still exist and still grant full data-plane access the moment someone re-enables the flag. Treat allow_shared_key_access=False as the control and key rotation as the fallback: rotate on a schedule so a key leaked into a log years ago is not a live credential the day someone flips the flag back for a migration.

Keep the account and its consumers in the same stack only if they share a lifecycle. A storage account outlives the applications that write to it. Putting it in a platform stack and exporting its id, then consuming that id via a stack reference, avoids the situation where tearing down a service stack proposes deleting the artifact store. The mechanics are covered in handling Pulumi stack outputs and cross-stack references.

Immutability policies are one-way. A legal-hold or time-based retention policy on a container cannot be shortened, and a locked policy prevents the container — and therefore the account — from being deleted at all. Never enable one from a program that a pulumi destroy is expected to be able to reverse; put those containers in a stack nobody destroys.

FAQ

Why can storage account names not contain hyphens?

The name becomes a DNS label in the account's endpoint hostnames, and Azure restricts that label to lowercase alphanumerics, 3–24 characters, unique across all of Azure.

Is allow_shared_key_access=False safe to set on an existing account?

Only after every consumer uses Entra ID auth. The flag takes effect immediately and any client still using an account key or connection string starts failing with 403.

Which replication SKU is the sensible default?

Standard_ZRS for production workloads in regions that support zones — it survives a zone outage at modest cost. Use GRS only when you genuinely need cross-region durability.

Should containers be separate Pulumi resources?

Yes. Declaring them explicitly makes their access level reviewable in code and lets Pulumi track drift on each one independently.

How do I get a connection string when shared-key access is disabled?

You do not — that is the point of the flag. Consumers authenticate with a managed identity or service principal and construct a BlobServiceClient from the endpoint plus a DefaultAzureCredential. If a third-party tool genuinely cannot do Entra ID auth, issue a user-delegation SAS (which is signed with an Entra ID key, not the account key) rather than re-enabling shared keys.

Why does pulumi preview show a replacement when I only changed a tag?

Check whether you also changed account_name or kind. Both are replacement-triggering inputs, and a change to either is easy to make accidentally when a name is interpolated from stack config. Run pulumi preview --diff and read the ~ versus +- markers: +- means delete-and-create, and on a storage account that means data loss.

Can Pulumi adopt a storage account that already exists?

Yes, with pulumi import azure-native:storage:StorageAccount platform /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Storage/storageAccounts/platformprodstore. Pulumi generates the matching Python, which you should diff against the code above — the generated form will spell out every default the account currently has, including the insecure ones you are about to change.