Teams Keep Rebuilding the Same Azure Patterns? Use Azure Verified Modules

Published on:

Each team can build its own storage account, Blob service, containers, security settings, diagnostics, and role assignments. The result is repeated Bicep code with different defaults, inconsistent security, and more code to maintain.

Azure Verified Modules (AVM) are versioned, Microsoft-maintained modules for common Azure resources and patterns. Replace the standard storage implementation with AVM, while keeping the CloudTrips wrapper responsible for its naming, tags, environment inputs, containers, and outputs.

Find the Storage Module

Open the Azure Verified Modules Bicep index and select the Storage Account resource module. Its page documents the supported parameters, outputs, examples, and versions.

This implementation uses:

br/public:avm/res/storage/storage-account:0.32.1
  • br/public is the alias for Microsoft’s public Bicep registry.
  • avm/res/storage/storage-account identifies the resource module.
  • 0.32.1 pins the version tested by CloudTrips.

Always specify a version. Upgrading an AVM is an infrastructure change that must pass review and what-if; it should not happen silently.

Azure Verified Modules index with the Bicep Storage Account module open

Replace the Custom Resource Implementation

The existing main.bicep already calls a local modules/storage.bicep wrapper. Keep that boundary, but replace the direct storage resources inside the wrapper with the verified module:

param applicationName string
param containerNames array
param costCenter string
param deployAuditContainer bool
param environment string
param location string
param owner string
param storageSku string

var normalizedEnvironment = toLower(environment)
var storageAccountName = 'stct${normalizedEnvironment}${uniqueString(resourceGroup().id)}'
var commonTags = {
  Application: applicationName
  CostCenter: costCenter
  Environment: toUpper(normalizedEnvironment)
  ManagedBy: 'Bicep'
  Owner: owner
}

var appContainers = [
  for containerName in containerNames: {
    name: containerName
    defaultEncryptionScope: '$account-encryption-key'
    denyEncryptionScopeOverride: false
    publicAccess: 'None'
  }
]
var auditContainers = deployAuditContainer
  ? [
      {
        name: 'audit'
        defaultEncryptionScope: '$account-encryption-key'
        denyEncryptionScopeOverride: false
        publicAccess: 'None'
      }
    ]
  : []
var blobContainers = concat(appContainers, auditContainers)

module storageAccount 'br/public:avm/res/storage/storage-account:0.32.1' = {
  name: '${deployment().name}-avm-storage'
  params: {
    name: storageAccountName
    location: location
    tags: commonTags
    skuName: storageSku
    kind: 'StorageV2'
    allowBlobPublicAccess: false
    allowCrossTenantReplication: false
    allowSharedKeyAccess: true
    blobServices: {
      containerDeleteRetentionPolicyEnabled: true
      containerDeleteRetentionPolicyDays: 7
      deleteRetentionPolicyEnabled: true
      deleteRetentionPolicyDays: 7
      deleteRetentionPolicyAllowPermanentDelete: false
      containers: blobContainers
    }
    defaultToOAuthAuthentication: false
    enableTelemetry: false
    minimumTlsVersion: 'TLS1_2'
    networkAcls: {
      bypass: 'None'
      defaultAction: 'Allow'
    }
    requireInfrastructureEncryption: false
    supportsHttpsTrafficOnly: true
  }
}

output storageAccountName string = storageAccount.outputs.name
output storageAccountId string = storageAccount.outputs.resourceId
output blobEndpoint string = storageAccount.outputs.primaryBlobEndpoint
output containerIds array = [
  for containerName in containerNames: resourceId(
    'Microsoft.Storage/storageAccounts/blobServices/containers',
    storageAccountName,
    'default',
    containerName
  )
]
output auditContainerId string? = deployAuditContainer
  ? resourceId(
      'Microsoft.Storage/storageAccounts/blobServices/containers',
      storageAccountName,
      'default',
      'audit'
    )
  : null

The local wrapper still exposes the same parameters and outputs to main.bicep. It converts the CloudTrips container arrays into the AVM input format and maps AVM outputs back to the existing contract.

The firewall bypass and container-encryption properties are explicit because they already exist in TEST. AVM has its own defaults; relying on them during a refactor could change a live setting. Telemetry is disabled for this learning deployment.

Restore and Validate the Module

From cloudtrips-bicep, run:

az bicep restore \
  --file modules/storage.bicep

az bicep lint \
  --file modules/storage.bicep

az bicep build-params \
  --file environments/test.bicepparam \
  --stdout > /dev/null

restore downloads the pinned module into Bicep’s local cache. The repository stores only the versioned module reference, not a copied AVM implementation. In VS Code, use Go to Definition on the module reference to inspect the restored source.

Review the Refactor Before Accepting It

The committed snapshot still describes the hand-written implementation. Run the snapshot validation first:

"$HOME/.azure/bin/bicep" snapshot environments/test.bicepparam \
  --mode validate \
  --subscription-id 00000000-0000-0000-0000-000000000000 \
  --resource-group rg-cloudtrips-bicep-test-weu \
  --location westeurope \
  --deployment-name deploy-cloudtrips-storage-test

It should fail because AVM compiles to a different nested template with more explicit provider properties. This is expected, but it is not yet proof that the change is safe.

Select CloudTrips TEST and compare the compiled template with Azure:

az account set \
  --subscription "<CloudTrips TEST subscription name or ID>"

az deployment group what-if \
  --resource-group rg-cloudtrips-bicep-test-weu \
  --parameters environments/test.bicepparam \
  --validation-level Provider

The existing storage account should keep the same name, resource ID, SKU, tags, TLS configuration, firewall behavior, and public-access setting. The three containers should report NoChange. The storage account and Blob service can report Modify because AVM sends explicit default properties that the original template omitted, and Azure normalizes some optional properties. There must be no resource deletion, creation, or replacement.

Bicep what-if showing no replacement and NoChange for all three CloudTrips containers

After reviewing what-if, approve the new compiled baseline:

"$HOME/.azure/bin/bicep" snapshot environments/test.bicepparam \
  --mode overwrite \
  --subscription-id 00000000-0000-0000-0000-000000000000 \
  --resource-group rg-cloudtrips-bicep-test-weu \
  --location westeurope \
  --deployment-name deploy-cloudtrips-storage-test

Run the same command again with --mode validate. It should now finish without a difference.

Deploy the Reviewed Version

Commit the wrapper and refreshed snapshot:

git add modules/storage.bicep \
  environments/test.snapshot.json

git commit -m "Use AVM for the CloudTrips storage account"

Push the feature branch and open a pull request. The configured GitHub Actions or Azure DevOps pipeline restores the public dependency, validates the snapshot, runs live preflight and what-if, and deploys the approved version.

Successful pipeline deployment after adopting the Azure Verified Storage Account module

CloudTrips now reuses a reviewed Azure implementation without losing its own standards. AVM reduces repeated resource code; it does not replace architecture decisions, pinned-version review, what-if, or testing.