Managing Azure Storage Accounts with Pulumi

Learn how to provision an Azure Storage Account with a private blob container and a lifecycle management policy using Pulumi and TypeScript

Managing Azure Storage Accounts with Pulumi

This guide provisions an Azure Storage Account with Pulumi - TLS-1.2-only, no public blob access, a private container, and a lifecycle management policy that tiers old blobs down automatically.

The Storage Account

// index.ts
import * as pulumi from "@pulumi/pulumi";
import * as storage from "@pulumi/azure-native/storage";

const storageAccount = new storage.StorageAccount("appdata", {
    resourceGroupName: resourceGroup.name,
    location: location,
    accountName: "myappdatasa001",
    sku: { name: storage.SkuName.Standard_LRS },
    kind: storage.Kind.StorageV2,
    minimumTlsVersion: storage.MinimumTlsVersion.TLS1_2,
    allowBlobPublicAccess: false,
    enableHttpsTrafficOnly: true,
});

accountName (not the Pulumi logical name "appdata") is what has to be globally unique across all of Azure, lowercase letters and numbers only, 3-24 characters - the same constraint the azurerm_storage_account Terraform resource has, since both go through the same ARM API.

Blob Container

const container = new storage.BlobContainer("data", {
    resourceGroupName: resourceGroup.name,
    accountName: storageAccount.name,
    containerName: "data",
    publicAccess: storage.PublicAccess.None,
});

publicAccess: storage.PublicAccess.None keeps the container private even though allowBlobPublicAccess is already false at the account level - the account-level flag is a hard override (nothing in the account can ever be public regardless of container settings), while the container-level setting is what you’d flip if you ever needed one specific container to be public while keeping the account flag permissive. Setting both to their most restrictive value is the safe default.

Lifecycle Management Policy

const lifecyclePolicy = new storage.ManagementPolicy("archive-old-blobs", {
    resourceGroupName: resourceGroup.name,
    accountName: storageAccount.name,
    managementPolicyName: "default",
    policy: {
        rules: [{
            name: "archive-after-30-days",
            enabled: true,
            type: "Lifecycle",
            definition: {
                actions: {
                    baseBlob: {
                        tierToCool: { daysAfterModificationGreaterThan: 30 },
                        tierToArchive: { daysAfterModificationGreaterThan: 90 },
                    },
                },
                filters: {
                    blobTypes: ["blockBlob"],
                },
            },
        }],
    },
});

managementPolicyName must literally be "default" - a storage account has exactly one management policy resource, unlike lifecycle rules on other clouds’ object stores where you can have many independently-named rule sets. Everything about which blobs get which treatment lives inside the single rules array.

Stack Outputs

export const storageAccountName = storageAccount.name;
export const primaryEndpoint = storageAccount.primaryEndpoints.blob;

Best Practices

  1. allowBlobPublicAccess: false and minimumTlsVersion: TLS1_2 on every account by default - both are opt-in weaknesses from the account’s actual defaults if left unset, not opt-in protections.
  2. Use lifecycle policies instead of manual cleanup jobs for anything with a predictable access pattern (logs, backups, old versions) - it’s declarative, versioned with the rest of your infrastructure, and doesn’t need its own compute to run.
  3. Prefer a managed identity + RBAC role assignment over storage account keys for anything other than the account itself needing keys (like the Function App’s AzureWebJobsStorage setting in the Functions guide) - keys are long-lived, unscoped bearer credentials.

Conclusion

The account/container/policy split here maps directly onto azurerm_storage_account/azurerm_storage_container/azurerm_storage_management_policy in Terraform - if you’ve used the Azure Terraform guides on this site, the shape is immediately familiar even though the property names (ARM-native casing) differ.

For more Pulumi topics, check out: