Bicep Modules and the Private Registry: Stop Copy-Pasting Templates

Here is a pattern I see in almost every organisation that has been writing Bicep for more than a year. There is a repository per application team. Each one has a modules/ folder. Each modules/ folder has a keyvault.bicep that started life as a copy of somebody else's, and each copy has diverged. One has soft delete on, one does not. One takes an array of access policies because it was written before RBAC mode. Nobody knows which is the good one.
That is not a Bicep problem, it is a distribution problem. Local module files can only be shared by copying them, and anything shared by copying will diverge. The fix is publishing modules to a registry and consuming them by version, which is the same thing every other language ecosystem worked out decades ago.
This is the rung between writing your first Bicep template and deploying a full environment. If you have already adopted Azure Verified Modules for standard resources, this is how you handle everything AVM does not cover, which is all of your organisation's own conventions.
New to Bicep entirely? Start at the beginning of the series and come back:
What Makes a Good Module
Before publishing anything, it is worth being deliberate about what a module is for, because a bad module published to a registry is worse than a bad module in a folder. Now it is somebody else's problem too.
A module should do one thing, take the minimum parameters that thing needs, and return the identifiers a caller will want. The failure mode is a module that takes twenty-eight parameters because someone kept adding one rather than deciding the module had the wrong boundary.
Here is a key vault module that earns its place, because it encodes decisions rather than just wrapping a resource:
metadata name = 'Key Vault'
metadata description = 'Key Vault with org defaults: RBAC auth, soft delete, purge protection.'
@description('Name of the key vault. Must be globally unique.')
@minLength(3)
@maxLength(24)
param name string
@description('Location for the vault. Defaults to the resource group location.')
param location string = resourceGroup().location
@description('Log Analytics workspace for diagnostics. Empty disables diagnostics.')
param logAnalyticsWorkspaceId string = ''
@description('Principal IDs granted Key Vault Secrets User.')
param secretsReaderPrincipalIds array = []
param tags object = {}
var secretsUserRoleId = '4633458b-17de-408a-b874-0445c86b69e6'
resource vault 'Microsoft.KeyVault/vaults@2023-07-01' = {
name: name
location: location
tags: tags
properties: {
tenantId: subscription().tenantId
sku: { family: 'A', name: 'standard' }
enableRbacAuthorization: true
enableSoftDelete: true
softDeleteRetentionInDays: 90
enablePurgeProtection: true
publicNetworkAccess: 'Disabled'
networkAcls: { defaultAction: 'Deny', bypass: 'AzureServices' }
}
}
resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!empty(logAnalyticsWorkspaceId)) {
name: 'diag-${name}'
scope: vault
properties: {
workspaceId: logAnalyticsWorkspaceId
logs: [ { categoryGroup: 'allLogs', enabled: true } ]
metrics: [ { category: 'AllMetrics', enabled: true } ]
}
}
resource secretsReaders 'Microsoft.Authorization/roleAssignments@2022-04-01' = [
for principalId in secretsReaderPrincipalIds: {
name: guid(vault.id, principalId, secretsUserRoleId)
scope: vault
properties: {
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', secretsUserRoleId)
principalId: principalId
principalType: 'ServicePrincipal'
}
}
]
@description('Resource ID of the key vault.')
output resourceId string = vault.id
@description('Vault URI for use in app settings.')
output vaultUri string = vault.properties.vaultUri
@description('Name of the key vault.')
output name string = vault.name
Look at what is not a parameter. Soft delete, purge protection, RBAC mode and public network access are all hardcoded, because those are the organisation's decisions and a module exists to stop people relitigating them per deployment. If everything is a parameter, you have not built a module, you have built a slightly awkward alias for the resource.
Pro tip:
metadata nameandmetadata descriptionat the top of the file show up in the registry and in editor tooling. They cost you two lines and they are the difference between a discoverable module and a filename somebody has to open to understand.
Never Output a Secret
One rule before this goes anywhere near a registry. Module outputs are written into the deployment history in plain text, and anyone with reader access to the resource group can read them.
// Never do this
output connectionString string = storage.listKeys().keys[0].value
Output the resource ID and let the caller fetch the secret at runtime with a managed identity. I have found connection strings sitting in six-month-old deployment histories more than once, and rotating them is the easy part. Working out who read them is not.
Step 1: Create the Registry
A Bicep module registry is just an Azure Container Registry. Nothing special about it.
RG="rg-platform-shared"
ACR="acrbicepmodules"
LOCATION="canadacentral"
az group create --name $RG --location $LOCATION
az acr create \
--resource-group $RG \
--name $ACR \
--sku Basic \
--location $LOCATION
Basic is genuinely fine. Bicep modules are tiny, and the storage included with Basic will hold thousands of them. Do not let anyone talk you into Premium unless you need geo-replication or private link for other reasons.
Permissions are the part worth thinking about. Publishers need push, consumers need pull, and those should not be the same group.
ACR_ID=$(az acr show --name $ACR --query id -o tsv)
# The platform team publishes
az role assignment create \
--assignee $(az ad group show --group "sg-platform-engineers" --query id -o tsv) \
--role "AcrPush" --scope $ACR_ID
# Everyone else, and every build agent, consumes
az role assignment create \
--assignee $(az ad group show --group "sg-app-engineers" --query id -o tsv) \
--role "AcrPull" --scope $ACR_ID
Step 2: Publish a Module
az bicep publish \
--file ./modules/key-vault.bicep \
--target br:$ACR.azurecr.io/bicep/modules/key-vault:1.0.0 \
--documentation-uri https://github.com/contoso/bicep-modules/blob/main/modules/key-vault.md
The target path has a shape worth standardising early, because changing it later means every consumer updates their references. I use bicep/modules/NAME:SEMVER. Whatever you pick, write it down and be consistent.
--documentation-uri surfaces in tooling when someone hovers the module reference. Use it.
Warning: A published tag is immutable in practice even though ACR will let you overwrite it. If you push a fix over
1.0.0, anyone who already deployed against it has a template that no longer reproduces. Publish1.0.1instead. Treat tags as write-once and you avoid an entire class of "it worked last week" problem.
Step 3: Consume It
The full reference works but it is verbose and it hardcodes the registry hostname into every file:
module kv 'br:acrbicepmodules.azurecr.io/bicep/modules/key-vault:1.0.0' = {
name: 'kv-deploy'
params: {
name: 'kv-app-prod'
logAnalyticsWorkspaceId: lawId
secretsReaderPrincipalIds: [ appIdentity.properties.principalId ]
tags: tags
}
}
Add a bicepconfig.json at the repository root and it gets considerably nicer:
{
"moduleAliases": {
"br": {
"contoso": {
"registry": "acrbicepmodules.azurecr.io",
"modulePath": "bicep/modules"
}
}
}
}
Now the reference reads:
module kv 'br/contoso:key-vault:1.0.0' = {
name: 'kv-deploy'
params: {
name: 'kv-app-prod'
logAnalyticsWorkspaceId: lawId
secretsReaderPrincipalIds: [ appIdentity.properties.principalId ]
tags: tags
}
}
That is the form to standardise on. The alias means the registry can move without touching every template, and the reference reads like a package name rather than a URL.
Step 4: Publish From CI, Not From Laptops
A module published from someone's machine is a module nobody can reproduce. Publishing belongs in a pipeline triggered by a tag.
name: Publish Bicep Modules
on:
push:
tags: [ 'modules/v*' ]
permissions:
id-token: write
contents: read
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Derive version from tag
run: echo "VERSION=${GITHUB_REF_NAME#modules/v}" >> $GITHUB_ENV
- name: Lint before publishing
run: |
for f in modules/*.bicep; do
az bicep build --file "$f" --stdout > /dev/null
done
- name: Publish
run: |
for f in modules/*.bicep; do
name=$(basename "$f" .bicep)
az bicep publish \
--file "$f" \
--target br:${{ vars.ACR_NAME }}.azurecr.io/bicep/modules/$name:$VERSION
done
Federated credentials rather than a secret, a lint pass before publish, and the version derived from the git tag so the registry and the repository agree about what 1.2.0 contains.
Versioning Without Overthinking It
Semver, applied to the module's interface rather than its implementation.
- Patch for a fix that does not change parameters or outputs
- Minor for a new optional parameter or a new output
- Major for a renamed or removed parameter, a changed default, or a removed output
That third bullet includes changed defaults, and people miss it. Flipping publicNetworkAccess from Enabled to Disabled is a breaking change even though the parameter list is identical, because it changes what happens to callers who did not touch anything.
Gotcha: Bicep does not resolve version ranges. There is no
^1.0.0. Every consumer pins an exact version, which means an upgrade is a pull request in every consuming repository. That is more work and it is also the reason a registry upgrade cannot silently break production.
When Not To Publish a Module
It is used in one place. A module consumed once is indirection with extra steps. Leave it inline until there is a second caller.
AVM already covers it. If you are publishing a storage account module that does what avm/res/storage/storage-account does, you have taken on maintenance for no gain. Publish modules for your conventions, not for Azure's resources.
It is still changing weekly. Publishing implies stability. Something in active design should stay a local file in one repository until its shape settles.
Conclusions
The registry is not the interesting part, the discipline is. Modules encode decisions, versions make those decisions reviewable, and publishing from CI makes them reproducible. The modules/ folder full of divergent copies goes away because there is finally somewhere better for a module to live.
Start with the two or three modules where your organisation actually has an opinion, usually networking, key vault and whatever your logging standard is. Publish those, alias the registry in bicepconfig.json, and let AVM handle the rest.
What's Next
- Testing Bicep with what-if and PSRule, which matters more once modules are shared and a bad publish reaches several teams
- Azure Verified Modules for the resources you should not be maintaining yourself
- Bicep vs Terraform on Azure if module ecosystems are part of how you are choosing between them




