Back to CourseAdvance5 min read
Azure /

Azure Verified Modules and Deployment Stacks

Use Azure Verified Modules for maintained infrastructure building blocks, and deployment stacks to manage resources as one unit.

Bishal Biswas

Bishal Biswas

Self Learner

Published on: October 19, 2026
Last updated: September 7, 2026
Advance Topic

What Azure Verified Modules are

Microsoft maintained modules for Bicep and Terraform, covering most Azure resource types, with security defaults, diagnostic settings and role assignment support built in.

Maintained by Microsoft, with a published support commitment
Secure by default, following Well-Architected guidance
Consistent interface across every module
Versioned, and published to a public registry
Available for both Bicep and Terraform

The alternative is every organisation writing the same storage account module slightly differently, and each one missing a different security setting.

Two kinds

Resource modules  one resource type. avm/res/storage/storage-account
Pattern modules   a working architecture. avm/ptn/...

Resource modules are the building blocks. Pattern modules assemble several into something complete, like a landing zone or a hub and spoke network.

Using one

module storage 'br/public:avm/res/storage/storage-account:0.14.3' = {
  name: 'storage-deployment'
  params: {
    name: 'stshopprod'
    location: location
    skuName: 'Standard_ZRS'
    kind: 'StorageV2'

    allowBlobPublicAccess: false
    allowSharedKeyAccess: false
    publicNetworkAccess: 'Disabled'
    minimumTlsVersion: 'TLS1_2'

    blobServices: {
      deleteRetentionPolicyEnabled: true
      deleteRetentionPolicyDays: 30
      containerDeleteRetentionPolicyEnabled: true
      containers: [
        { name: 'uploads', publicAccess: 'None' }
        { name: 'exports', publicAccess: 'None' }
      ]
    }

    privateEndpoints: [
      {
        subnetResourceId: subnetId
        service: 'blob'
        privateDnsZoneGroup: {
          privateDnsZoneGroupConfigs: [ { privateDnsZoneResourceId: blobZoneId } ]
        }
      }
    ]

    roleAssignments: [
      {
        principalId: identity.properties.principalId
        roleDefinitionIdOrName: 'Storage Blob Data Contributor'
        principalType: 'ServicePrincipal'
      }
    ]

    diagnosticSettings: [
      {
        workspaceResourceId: workspaceId
        logCategoriesAndGroups: [ { categoryGroup: 'allLogs' } ]
        metricCategories: [ { category: 'AllMetrics' } ]
      }
    ]

    tags: tags
  }
}

output blobEndpoint string = storage.outputs.primaryBlobEndpoint

That single module call creates the account, its containers, a private endpoint with DNS wiring, a role assignment and diagnostic settings. Writing the equivalent by hand is roughly a hundred and fifty lines, and easy to get subtly wrong.

Pin the version. :0.14.3 is reproducible. Omitting it means a later deployment may behave differently.

The consistent interface

Every module supports the same optional parameters, which is most of the value.

roleAssignments      the same shape everywhere
diagnosticSettings   the same shape everywhere
privateEndpoints     the same shape everywhere
lock                 CanNotDelete or ReadOnly
managedIdentities    systemAssigned and userAssignedResourceIds
tags                 everywhere
enableTelemetry      set false to opt out of usage telemetry

Learn the shape once and it applies to every module. Adding diagnostic settings to a Key Vault uses the same block as adding them to a storage account.

Finding modules

az rest --method get \
  --url "https://mcr.microsoft.com/v2/bicep/avm/res/storage/storage-account/tags/list" \
  --query "tags[-5:]"

The index is published at the Azure Verified Modules site, listing every module, its status and its owner. Modules move through proposed, orphaned and available states, so check the status before depending on one.

Terraform

module "storage" {
  source  = "Azure/avm-res-storage-storageaccount/azurerm"
  version = "0.6.4"

  name                          = "stshopprod"
  resource_group_name           = azurerm_resource_group.shop.name
  location                      = var.location
  account_replication_type      = "ZRS"
  shared_access_key_enabled     = false
  public_network_access_enabled = false

  containers = {
    uploads = { name = "uploads" }
    exports = { name = "exports" }
  }

  role_assignments = {
    blob_contributor = {
      role_definition_id_or_name = "Storage Blob Data Contributor"
      principal_id               = azurerm_user_assigned_identity.shop.principal_id
      principal_type             = "ServicePrincipal"
    }
  }
}

Same programme, published to the Terraform registry, with the same consistent interface.

When to write your own

Use AVM when          the module covers what you need
Wrap AVM when         you have organisational conventions to apply
Write your own when   the resource is unsupported, or your needs differ substantially

Wrapping is often the right answer:

// modules/org-storage.bicep
param name string
param location string
param environment string

module storage 'br/public:avm/res/storage/storage-account:0.14.3' = {
  name: 'avm-storage'
  params: {
    name: name
    location: location
    skuName: environment == 'prod' ? 'Standard_GZRS' : 'Standard_LRS'

    // organisational policy, applied to every account
    allowSharedKeyAccess: false
    publicNetworkAccess: 'Disabled'
    minimumTlsVersion: 'TLS1_2'

    diagnosticSettings: [ { workspaceResourceId: centralWorkspaceId } ]
    tags: union(commonTags, { environment: environment })
  }
}

output id string = storage.outputs.resourceId

Your team calls one module with three parameters, and organisational decisions are applied without anyone remembering them.

Deployment stacks

A related idea: managing a set of resources as one unit with a defined deletion behaviour.

az stack group create \
  --name shop-prod \
  --resource-group rg-shop-prod-uks \
  --template-file main.bicep \
  --parameters prod.bicepparam \
  --action-on-unmanage deleteResources \
  --deny-settings-mode denyWriteAndDelete \
  --deny-settings-excluded-principals "$PIPELINE_PRINCIPAL_ID"

Two capabilities that ordinary deployments do not have:

--action-on-unmanage decides what happens to a resource removed from the template. deleteResources deletes it, detachAll leaves it. That solves the problem where removing a resource from a Bicep file leaves it running and billing forever.

--deny-settings-mode blocks changes outside the stack. denyWriteAndDelete means nobody can modify or delete a managed resource, including through the portal, except the excluded principals.

That directly addresses the biggest discipline problem in infrastructure as code: people changing things in the portal.

az stack group show --name shop-prod --resource-group rg-shop-prod-uks \
  --query "resources[].{Id:id, Status:status, DenyStatus:denyStatus}" -o table

az stack group delete --name shop-prod --resource-group rg-shop-prod-uks \
  --action-on-unmanage deleteAll --yes

Deleting the stack with deleteAll removes every managed resource, which makes cleaning up an environment one command instead of a careful list.

Stacks at other scopes

az stack sub create \
  --name platform --location uksouth \
  --template-file platform.bicep \
  --action-on-unmanage deleteResources \
  --deny-settings-mode denyDelete

az stack mg create \
  --name governance --management-group-id mg-contoso --location uksouth \
  --template-file policy.bicep --action-on-unmanage detachAll \
  --deny-settings-mode none

Subscription and management group stacks manage resource groups and policy assignments, which is how a platform team applies governance as one unit.

Practical advice

Start with AVM modules rather than writing your own
Pin versions, and update deliberately
Wrap them where organisational conventions apply
Use deployment stacks for environments with a clear lifecycle
Use deny settings to stop portal drift
Test action-on-unmanage in a non production stack first

That last one matters. deleteResources does exactly what it says, and discovering that on production is not the way to learn it.

Quick recap

  • Azure Verified Modules are maintained by Microsoft with security defaults already applied.
  • Every module shares the same interface for roles, diagnostics, private endpoints and locks.
  • Pin module versions, since omitting one makes deployments non reproducible.
  • Wrapping an AVM module is how you apply organisational conventions in one place.
  • Deployment stacks decide what happens to resources removed from a template.
  • Deny settings block portal changes to managed resources, which addresses drift directly.
  • Test action-on-unmanage outside production, because deleteResources means it.
deploy.sh

// AZURE VERIFIED MODULES are Microsoft maintained, secure by default, and
// share ONE CONSISTENT INTERFACE across every resource type. The
// alternative is every organisation writing the same storage module
// slightly differently, each missing a different security setting.
//   avm/res/...  one resource type      avm/ptn/...  a working architecture
//
module storage 'br/public:avm/res/storage/storage-account:0.14.3' = {
  //                                                       ^^^^^^ PIN IT.
  // Omitting the version means a later deployment may behave differently.
  name: 'storage-deployment'
  params: {
    name: 'stshopprod'
    location: location
    skuName: 'Standard_ZRS'
    allowSharedKeyAccess: false
    publicNetworkAccess: 'Disabled'
//
    // THE SAME SHAPES APPEAR IN EVERY MODULE. Learn them once.
    roleAssignments: [{
      principalId: identity.properties.principalId
      roleDefinitionIdOrName: 'Storage Blob Data Contributor'
      principalType: 'ServicePrincipal'
    }]
    diagnosticSettings: [{
      workspaceResourceId: workspaceId
      logCategoriesAndGroups: [{ categoryGroup: 'allLogs' }]
    }]
    privateEndpoints: [{
      subnetResourceId: subnetId
      service: 'blob'
      privateDnsZoneGroup: {
        privateDnsZoneGroupConfigs: [{ privateDnsZoneResourceId: blobZoneId }]
      }
    }]
    lock: { kind: 'CanNotDelete' }
  }
}
// That one call creates the account, containers, a private endpoint WITH
// DNS wiring, a role assignment and diagnostics. By hand it is roughly 150
// lines and easy to get subtly wrong.
//
// WRAPPING IS OFTEN THE RIGHT ANSWER: your team calls one module with three
// parameters, and organisational decisions apply without anyone
// remembering them.
//   module storage 'br/public:avm/res/storage/storage-account:0.14.3' = {
//     params: { allowSharedKeyAccess: false, publicNetworkAccess: 'Disabled',
//               diagnosticSettings: [{ workspaceResourceId: centralWorkspaceId }] }
//   }
//
// DEPLOYMENT STACKS manage a set of resources AS ONE UNIT, with two things
// ordinary deployments do not have:
//
//   --action-on-unmanage  what happens to a resource REMOVED FROM THE
//     TEMPLATE. deleteResources deletes it; detachAll leaves it. This
//     solves resources that keep running and billing forever after
//     someone deletes them from the Bicep file.
//
//   --deny-settings-mode  BLOCKS CHANGES OUTSIDE THE STACK, including
//     through the portal, except for excluded principals. That directly
//     addresses the biggest discipline problem in infrastructure as code.
//
//   az stack group create --name shop-prod --resource-group rg-shop-prod-uks \
//     --template-file main.bicep --parameters prod.bicepparam \
//     --action-on-unmanage deleteResources \
//     --deny-settings-mode denyWriteAndDelete \
//     --deny-settings-excluded-principals "$PIPELINE_PRINCIPAL_ID"
//
//   az stack group delete --name shop-prod --resource-group rg-shop-prod-uks \
//     --action-on-unmanage deleteAll --yes    # cleanup in ONE command
//
// TEST action-on-unmanage OUTSIDE PRODUCTION FIRST. deleteResources does
// exactly what it says, and production is not the place to learn that.

Was this explanation helpful?

Your feedback helps us improve the content for everyone.