Azure Verified Modules: The New Standard for Bicep in Production

If you have written Bicep in the last few years, you have almost certainly built the same storage account module three or four times. Slightly different parameter names each time, private endpoints bolted on in one version and not another, diagnostic settings in the one you wrote after the audit. Every client I have worked with has a modules/ folder that is really an archaeology site.
Microsoft has now taken a position on this, and it is a firmer one than most people have registered. Azure Verified Modules are generally available, AVM is the single standard for Bicep modules in the public registry, and proposals for new non-AVM modules are no longer accepted. Existing non-AVM registry modules have been retired or migrated.
There is a date attached too. The Azure Landing Zones accelerator moved to AVM as its default, classic ALZ-Bicep was removed from the accelerator in February 2026, and the ALZ-Bicep repository is archived in February 2027. If your platform code is built on the classic modules, that is a migration with a deadline on it, and it is worth understanding what you are migrating to before it becomes urgent.
If you want to see where modular Bicep ends up, I deploy a whole environment from a single file here:
What AVM Actually Is
AVM is a specification, not a product. It defines what a good Infrastructure as Code module looks like, and then a set of modules that conform to it, published across Bicep and Terraform.
The specification is the interesting part. Every module that carries the AVM badge has to support a common set of capabilities: diagnostic settings, private endpoints where the service supports them, role assignments, managed identity, customer-managed keys, tags, and locks. Not "supports if the author got round to it". Required, tested, and enforced in the module's own CI.
That is why this matters more than a shared module library. The value is not that someone else wrote a storage account module. It is that every module behaves the same way, so once you have learned the shape of one, you have learned all of them.
There are two kinds:
| Type | Path | What it is | Count |
|---|---|---|---|
| Resource module | avm/res/... | One Azure service, fully specified | 171 published |
| Pattern module | avm/ptn/... | Several resources composing a scenario | 41 published |
Resource modules are the building blocks. Pattern modules are things like hub networking or subscription vending, where the useful unit is an architecture rather than a resource.
Consuming a Module
The syntax is a registry reference with a pinned version.
module storage 'br/public:avm/res/storage/storage-account:0.30.0' = {
name: 'storage-deploy'
params: {
name: 'stapp${uniqueString(resourceGroup().id)}'
location: location
skuName: 'Standard_LRS'
kind: 'StorageV2'
allowBlobPublicAccess: false
minimumTlsVersion: 'TLS1_2'
}
}
Break that path down, because it is consistent everywhere:
br/publicis the public Bicep registryavm/resis a resource module,avm/ptna pattern modulestorage/storage-accountis service then resource0.30.0is the pinned version
Warning: Pin the version. Always. A floating reference means your deployment output changes when the module author ships a release, and you find out during a production deploy rather than in a pull request. This is the single most important habit with any registry module.
What You Get For Free
Here is the part that changes how much code you write. Take that same storage account and add the things a real environment needs.
module storage 'br/public:avm/res/storage/storage-account:0.30.0' = {
name: 'storage-deploy'
params: {
name: 'stapp${uniqueString(resourceGroup().id)}'
location: location
skuName: 'Standard_ZRS'
allowBlobPublicAccess: false
publicNetworkAccess: 'Disabled'
// Diagnostics, without writing a diagnosticSettings resource
diagnosticSettings: [
{
workspaceResourceId: logAnalyticsWorkspaceId
logCategoriesAndGroups: [ { categoryGroup: 'allLogs' } ]
metricCategories: [ { category: 'AllMetrics' } ]
}
]
// Private endpoint, without writing the NIC, the DNS zone group, or the link
privateEndpoints: [
{
subnetResourceId: privateEndpointSubnetId
service: 'blob'
privateDnsZoneGroup: {
privateDnsZoneGroupConfigs: [ { privateDnsZoneResourceId: blobDnsZoneId } ]
}
}
]
// RBAC, without writing role assignment resources or looking up GUIDs
roleAssignments: [
{
principalId: appIdentityPrincipalId
roleDefinitionIdOrName: 'Storage Blob Data Contributor'
principalType: 'ServicePrincipal'
}
]
lock: { kind: 'CanNotDelete', name: 'prevent-delete' }
tags: tags
}
}
Written by hand that is a storage account, a diagnostic setting, a private endpoint, a network interface, a private DNS zone group, a role assignment with a hardcoded role definition GUID, and a lock. Roughly 90 lines, and the role GUID is the bit somebody always gets wrong.
Note roleDefinitionIdOrName accepting a plain role name. The module resolves built-in roles for you, which removes an entire category of copy-paste error.
Converting an Existing Template
The migration is mostly deletion, which is a pleasant surprise. Take a typical hand-rolled module:
// Before: your own module, called from main.bicep
module vnet './modules/network/vnet.bicep' = {
name: 'vnet-deploy'
params: {
vnetName: 'vnet-app-prod'
addressPrefix: '10.20.0.0/16'
subnets: subnetConfig
location: location
tags: tags
}
}
The AVM equivalent:
module vnet 'br/public:avm/res/network/virtual-network:0.7.2' = {
name: 'vnet-deploy'
params: {
name: 'vnet-app-prod'
addressPrefixes: [ '10.20.0.0/16' ]
location: location
tags: tags
subnets: [
{
name: 'snet-app'
addressPrefix: '10.20.1.0/24'
networkSecurityGroupResourceId: appNsgId
}
{
name: 'snet-pe'
addressPrefix: '10.20.2.0/24'
privateEndpointNetworkPolicies: 'Disabled'
}
]
}
}
The work is in the parameter names, not the logic. AVM standardises on name, location, tags, enableTelemetry, and the capability blocks. Your module probably called it vnetName, so most of the migration is a rename pass and then deleting the file.
Pro tip: Migrate one module at a time and run
what-ifafter each. A big-bang conversion produces a what-if diff nobody can read, and you will end up approving a plan you did not actually verify.
The Telemetry Question
Every AVM module ships with enableTelemetry defaulting to true. It deploys a tiny ARM deployment with a GUID in the name that tells Microsoft the module was used. No resource data, no parameter values, just a usage signal.
I mention it because it comes up in every regulated client conversation, and it is better to answer it before someone finds it in a deployment history and escalates.
module storage 'br/public:avm/res/storage/storage-account:0.30.0' = {
name: 'storage-deploy'
params: {
name: storageName
location: location
enableTelemetry: false
}
}
Set it once in a shared parameter and pass it down if your organisation has a policy about it.
Where AVM Does Not Fit
I am not going to pretend this is universally the right call.
You need something the module does not expose. AVM modules are opinionated and they do not surface every property of every resource. When you hit that wall, you either raise an issue upstream and wait, or you write your own module. Wrapping an AVM module to add one property usually ends up worse than writing your own.
Your deployment is genuinely simple. A single storage account with no diagnostics, no private endpoints and no RBAC does not need a module at all. A plain resource declaration is six lines and clearer.
You are pinned to an old Bicep CLI. Registry references need reasonably current tooling. If your build agents are running something ancient, resolve that before you commit to this.
Gotcha: AVM modules are versioned independently of each other. Upgrading your virtual network module does not upgrade your storage module, and there is no single "AVM version" for your project. You need a way to track and bump versions across modules, and Dependabot handles Bicep registry references if you are on GitHub.
Pinning and Upgrading Sensibly
Keep versions in one place rather than scattered through your templates.
// versions.bicep
@export()
var avm = {
storageAccount: 'br/public:avm/res/storage/storage-account:0.30.0'
virtualNetwork: 'br/public:avm/res/network/virtual-network:0.7.2'
keyVault: 'br/public:avm/res/key-vault/vault:0.13.0'
}
Bicep does not let you interpolate a module path from a variable, so this is a registry rather than an indirection. It still earns its place: one file to review when you upgrade, and one place to see what you are running.
When you upgrade, read the module's changelog rather than assuming semver protects you. These modules are pre-1.0, and a minor bump can change a default. Run what-if against a non-production subscription before you promote.
Conclusions
AVM is the direction Microsoft has committed to, and the migration deadline on classic ALZ-Bicep makes it real rather than aspirational. The practical case is simpler than the strategic one: the modules give you diagnostics, private endpoints and RBAC as parameters rather than as a hundred lines you maintain yourself, and every module behaves the same way.
Start with one resource type in one environment. Pin versions, keep them in a single file, and run what-if on every upgrade. If you are running platform code on ALZ-Bicep classic, start planning that migration now rather than in late 2026.
What's Next
- Bicep modules and the private registry for the modules AVM does not cover, which is where your own standards live
- Testing Bicep with what-if and PSRule so an AVM version bump cannot surprise you in production
- Bicep vs Terraform on Azure if you are still deciding which of these ecosystems to invest in




