The Philosophical Developer -- Chapter 32: Pulumi on Azure

2026-07-21 · 4 min read

Pulumi Go Port - Chapter 32: Pulumi on Azure

AWS and GCP were the proof points. Two clouds, same methodology, same patterns. But two is not a pattern. Three is a pattern. If the methodology works for AWS, GCP, and Azure, then it is truly provider-independent.

I sat down with OrsonRius and we ported the local-cloud-az infrastructure to Pulumi Go. Five modules this time, each one an Azure service with its own resource types and its own configuration surface.

The Azure module structure

The Azure local-cloud has five modules. Each one maps to an Azure service.

ModuleWhat It CreatesOpenTofu Equivalent
Resource GroupResource group + tagsmodules/resource-group
StorageStorage account + blob containermodules/storage
Container RegistryACR with admin credentialsmodules/container-registry
Cosmos DBAccount with Session consistencymodules/cosmos-db
Key VaultVault + secretmodules/key-vault

The Azure Pulumi SDK is the oldest of the three. The SDK path is github.com/pulumi/pulumi-azure/sdk/v6, and the package structure follows the Azure resource manager naming conventions.

The resource group module

The resource group is the foundation of every Azure deployment. Everything else depends on it.

func createResourceGroup(ctx *pulumi.Context) error {
    rg, err := core.NewResourceGroup(ctx, "local-cloud-az-rg",
        &core.ResourceGroupArgs{
            Name:     pulumi.String("rg-local-cloud-local-cloud"),
            Location: pulumi.String("westus"),
            Tags: pulumi.StringMap{
                "managed_by":  pulumi.String("pulumi"),
                "environment": pulumi.String("local"),
            },
        })
    if err != nil {
        return err
    }
    ctx.Export("resourceGroupName", rg.Name)
    return nil
}

The storage module

The Azure storage module is the most complex of the three cloud storage modules. It creates a resource group, a storage account, and a blob container. The storage account name is globally unique in Azure, limited to 24 lowercase alphanumeric characters.

account, err := storage.NewAccount(ctx, "local-cloud-az-storage",
    &storage.AccountArgs{
        Name:                   pulumi.String("startifactslocalcloud"),
        ResourceGroupName:      rg.Name,
        Location:               rg.Location,
        AccountTier:            pulumi.String("Standard"),
        AccountReplicationType: pulumi.String("LRS"),
    })

container, err := storage.NewContainer(ctx, "local-cloud-az-blobs",
    &storage.ContainerArgs{
        Name:                pulumi.String("artifacts"),
        StorageAccountName:  account.Name,
        ContainerAccessType: pulumi.String("private"),
    })

The container registry module

The container registry module creates an Azure Container Registry with Basic SKU and admin credentials enabled. The ACR name follows the same naming convention as the storage account, limited to 50 alphanumeric characters.

The Cosmos DB module

The Cosmos DB module creates a GlobalDocumentDB account with Session consistency. The configuration includes the geo-location for the primary region, which maps to the resource group location.

account, err := cosmosdb.NewAccount(ctx, "local-cloud-az-cosmos",
    &cosmosdb.AccountArgs{
        Name:                pulumi.String("cdb-local-cloud"),
        ResourceGroupName:   rg.Name,
        Location:            rg.Location,
        OfferType:           pulumi.String("Standard"),
        Kind:                pulumi.String("GlobalDocumentDB"),
        ConsistencyPolicy: &cosmosdb.AccountConsistencyPolicyArgs{
            ConsistencyLevel: pulumi.String("Session"),
        },
        GeoLocations: cosmosdb.AccountGeoLocationArray{
            &cosmosdb.AccountGeoLocationArgs{
                Location:         rg.Location,
                FailoverPriority: pulumi.Int(0),
            },
        },
    })

The key vault module

The key vault module creates a vault with a single secret. The vault configuration includes the tenant ID (a placeholder for local development) and the SKU name.

vault, err := keyvault.NewKeyVault(ctx, "local-cloud-az-kv",
    &keyvault.KeyVaultArgs{
        Name:              pulumi.String("kv-local-cloud"),
        ResourceGroupName: rg.Name,
        Location:          rg.Location,
        TenantId:          pulumi.String("00000000-0000-0000-0000-000000000000"),
        SkuName:           pulumi.String("standard"),
    })

secret, err := keyvault.NewSecret(ctx, "local-cloud-az-secret",
    &keyvault.SecretArgs{
        Name:       pulumi.String("example-secret"),
        Value:      pulumi.String("super-secret-value"),
        KeyVaultId: vault.ID(),
    })

The tests

The Azure tests follow the same pattern as AWS and GCP. A mock struct intercepts the resource creation calls. Each module gets its own test function. A full stack test composes all five modules.

The mock configuration for Azure is the most extensive because the Azure SDK has more resource types and more computed properties. The storage account, for example, generates a primary blob endpoint that the mock returns as a predictable URL.

=== RUN   TestAzureResourceGroupModule      --- PASS
=== RUN   TestAzureStorageModule            --- PASS
=== RUN   TestAzureContainerRegistryModule  --- PASS
=== RUN   TestAzureCosmosDBModule           --- PASS
=== RUN   TestAzureKeyVaultModule           --- PASS
=== RUN   TestAzureFullStack                --- PASS
PASS    ok  local-cloud-az-pulumi  0.012s

What we learned

Azure is the hardest of the three clouds to port. The resource naming conventions are strict. The dependencies between resources are more entangled. The SDK is the oldest and least ergonomic of the three.

But the methodology still holds. The same code structure, the same test patterns, the same CI pipeline. The Azure SDK is different from the AWS and GCP SDKs, but the wrapper functions abstract away the differences. The test file is the same shape. The CI job is the same steps.

The resource group pattern is the most important lesson. Every Azure resource needs a resource group, and each module in the original OpenTofu code creates its own resource group. The Pulumi port follows the same convention. Each module that needs a resource group creates one. This is not the most efficient approach for production, but it preserves the module independence that the methodology requires.

The code is in the local-cloud-az repo. The tests pass. Three clouds, three SDKs, one methodology.

Repos:

LinkedIn:

Here I geek out with my young Padawan, OrsonRius.