AzureBicepIaCazure-container-registry

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

Parveen Singh
August 20, 2026
9 min read
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 name and metadata description at 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. Publish 1.0.1 instead. 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

Recommended Readings