IaC Azure

Operating a Bicep Private Module Registry and Templating at Scale

A single team writing Bicep can get away with relative-path module references and a folder of .bicep files in one repo. An organization cannot. The moment three teams need the same hardened storage account, the same VNet-with-subnets pattern, or the same diagnostic-settings wiring, copy-paste becomes the architecture, and every drift, every CVE, every renamed parameter has to be chased across N repositories by hand. The fix is to treat Bicep modules like real software: typed interfaces, semantic versions, a private registry, automated tests, and a publishing pipeline. This is the principal-level playbook for running that ecosystem on an Azure Container Registry (ACR).

I assume az 2.60+ (which bundles a current Bicep CLI), an ACR you can push to, and that you have read or written Bicep before. Everything here targets GA features in Bicep 0.30+; I call out where the standalone CLI lags the az-bundled one.

1. Design reusable modules with typed params and decorators

A registry module is an API. Its parameters are the request schema, its outputs are the response, and consumers will pin to a version and expect both to be stable. Treat parameters with the same rigor you would a public function signature.

// modules/storage-account/main.bicep
metadata name = 'Hardened Storage Account'
metadata description = 'StorageV2 account with TLS1.2, no public blob, optional private endpoint.'

@description('Globally unique storage account name (3-24 lowercase alphanumeric).')
@minLength(3)
@maxLength(24)
param name string

@description('Azure region. Defaults to the resource group location.')
param location string = resourceGroup().location

@allowed([
  'Standard_LRS'
  'Standard_ZRS'
  'Standard_GRS'
])
param skuName string = 'Standard_LRS'

@description('Tags applied to every resource this module creates.')
param tags object = {}

resource sa 'Microsoft.Storage/storageAccounts@2023-05-01' = {
  name: name
  location: location
  tags: tags
  sku: { name: skuName }
  kind: 'StorageV2'
  properties: {
    minimumTlsVersion: 'TLS1_2'
    allowBlobPublicAccess: false
    supportsHttpsTrafficOnly: true
    publicNetworkAccess: 'Disabled'
  }
}

@description('Resource ID, for RBAC assignments and diagnostic wiring.')
output id string = sa.id

@description('The account name, echoed for convenience.')
output name string = sa.name

Three rules I enforce on every registry module:

Rule of thumb: if you cannot describe what a module produces in one sentence, it is doing too much. A module is one cohesive thing a team reasons about as a unit: a storage account with its network rules; a VNet with its subnets and NSG associations. Split anything with independent lifecycles.

2. Publish modules to an ACR-backed private registry

ACR is the OCI registry Bicep speaks natively. You do not need a special “Bicep registry” SKU; any ACR works, because Bicep modules are pushed as OCI artifacts. Create one with anonymous pull disabled:

az group create -n rg-platform-bicep -l eastus

az acr create \
  --resource-group rg-platform-bicep \
  --name contosoplatform \
  --sku Standard \
  --admin-enabled false

Keep --admin-enabled false. Authentication is Azure AD via your az login session; the admin account is a shared secret you do not want in a registry that gates production infrastructure. Pushing requires the AcrPush role and pulling requires AcrPull; assign them to the publishing service principal and to consumer identities respectively.

Publishing a module is one command. The target is a br: reference of the form br:<registry-login-server>/<module-path>:<tag>:

az bicep publish \
  --file modules/storage-account/main.bicep \
  --target "br:contosoplatform.azurecr.io/bicep/storage-account:1.0.0" \
  --documentation-uri "https://github.com/contoso/bicep-modules/tree/main/modules/storage-account" \
  --with-source

Two flags worth knowing. --documentation-uri stamps a link into the artifact manifest so tooling and the VS Code Bicep extension can point users at docs. --with-source embeds the original .bicep source alongside the compiled ARM, which lets consumers and the language service show the real module code (and lets you reconstruct it if the source repo is ever lost). There is also --force, which overwrites an existing tag.

The standalone bicep publish command is identical in shape:

bicep publish modules/storage-account/main.bicep \
  --target br:contosoplatform.azurecr.io/bicep/storage-account:1.0.0 \
  --with-source

Verify the artifact landed:

az acr repository show-tags \
  --name contosoplatform \
  --repository bicep/storage-account \
  --output table

3. Versioning, aliases in bicepconfig.json, and consumption

A consumer references a published module by its full br: path and a tag:

module storage 'br:contosoplatform.azurecr.io/bicep/storage-account:1.0.0' = {
  name: 'app-storage'
  params: {
    name: 'stappdata${uniqueString(resourceGroup().id)}'
    skuName: 'Standard_ZRS'
    tags: { env: 'prod', owner: 'app-team' }
  }
}

Typing the full registry FQDN everywhere is brittle. If you migrate registries or geo-replicate, you do not want to sed your entire estate. Define a module alias in bicepconfig.json at the consumer repo root:

{
  "moduleAliases": {
    "br": {
      "platform": {
        "registry": "contosoplatform.azurecr.io",
        "modulePath": "bicep"
      }
    }
  }
}

Now the reference collapses to the alias platform, and the modulePath prefix is implied:

module storage 'br/platform:storage-account:1.0.0' = {
  name: 'app-storage'
  params: { /* ... */ }
}

Versioning strategy

Tag modules with semantic versions and treat them as a public API:

Change Bump Example
New optional parameter (has a default) MINOR 1.0.0 -> 1.1.0
New output MINOR 1.1.0 -> 1.2.0
Bug fix, no interface change PATCH 1.2.0 -> 1.2.1
New required parameter MAJOR 1.2.1 -> 2.0.0
Removed/renamed param or output MAJOR 2.0.0 -> 3.0.0
Changed @allowed to a narrower set MAJOR breaks existing callers

Two hard rules that save you from incident reviews:

When a consumer first references a registry module, Bicep restores it into a local cache. CI agents are ephemeral, so restore explicitly before build:

bicep restore consumer/main.bicep
bicep build consumer/main.bicep --stdout > /dev/null

4. User-defined types, functions, and the import keyword

The biggest lever for a coherent module ecosystem is shared types and functions. Without them, every module re-declares its own subnetConfig shape and its own tag-building logic, and they drift. Bicep lets you export type aliases and functions from a module and import them elsewhere with the import keyword (GA since Bicep 0.30; the older standalone CLI may require the UserDefinedFunctions experimental flag, so build with the az-bundled Bicep).

Publish a shared library module:

// modules/shared/types.bicep
@export()
type storageSku = 'Standard_LRS' | 'Standard_ZRS' | 'Standard_GRS'

@export()
type subnetConfig = {
  name: string
  addressPrefix: string
}

@export()
@description('Returns the org-standard tag set.')
func buildTags(env string, owner string) object => {
  environment: env
  owner: owner
  managedBy: 'bicep'
  costCenter: 'platform'
}

Publish it like any other module:

az bicep publish \
  --file modules/shared/types.bicep \
  --target "br:contosoplatform.azurecr.io/bicep/shared/types:1.0.0" \
  --with-source

Consume the type and the function. Note that you import named symbols, and the imported type is then usable as a real parameter type:

import { storageSku, buildTags } from 'br/platform:shared/types:1.0.0'

param env string
param skuName storageSku = 'Standard_LRS'

var tags = buildTags(env, 'platform-team')

module storage 'br/platform:storage-account:1.2.0' = {
  name: 'app-storage'
  params: {
    name: 'stappdata${uniqueString(resourceGroup().id)}'
    skuName: skuName
    tags: tags
  }
}

When you compile this, Bicep emits ARM with languageVersion: "2.0" and stamps imported definitions with __bicep_imported_from! metadata pointing at the source template, so the provenance of every shared type is traceable in the compiled artifact. The payoff: change buildTags once, bump the shared/types version, and every module that adopts the new version gets consistent tagging without a single copy-paste.

5. Linting and analyzer rules, plus template tests

Bicep ships a built-in linter (the “core” analyzer). Configure it in bicepconfig.json and treat warnings as errors in CI:

{
  "analyzers": {
    "core": {
      "enabled": true,
      "rules": {
        "no-unused-params": { "level": "error" },
        "no-unused-vars": { "level": "error" },
        "no-hardcoded-env-urls": { "level": "error" },
        "secure-parameter-default": { "level": "error" },
        "secure-secrets-in-params": { "level": "error" },
        "use-stable-resource-identifiers": { "level": "warning" },
        "outputs-should-not-contain-secrets": { "level": "error" }
      }
    }
  }
}

Run the linter and emit SARIF so the results render in GitHub/Azure DevOps code-scanning UIs:

bicep lint modules/storage-account/main.bicep --diagnostics-format sarif > lint.sarif

bicep lint exits non-zero when any rule set to error fires, which is what gates the pipeline. The standalone and bundled CLIs accept the same --diagnostics-format sarif flag.

Template tests, ARM-TTK and Pester style

Linting checks Bicep hygiene; it does not assert that your compiled output meets organizational policy (“storage must disable public blob access”, “every resource must be tagged”). For that, compile to ARM and assert against the JSON. The classic tool is ARM Template Test Toolkit (ARM-TTK), a set of Pester tests Microsoft maintains:

# Compile Bicep to ARM, then run ARM-TTK over the output
bicep build modules/storage-account/main.bicep --outfile out/storage.json

Import-Module ./arm-ttk/arm-ttk.psd1
$results = Test-AzTemplate -TemplatePath ./out

# Fail the build if any test failed
if ($results.Where({ -not $_.Passed })) {
  $results.Where({ -not $_.Passed }) | Format-Table Name, Errors
  throw "ARM-TTK validation failed"
}

For assertions ARM-TTK does not cover, write plain Pester tests against the compiled JSON. This is where you encode your own guardrails:

Describe 'storage-account module' {
  BeforeAll {
    bicep build ./modules/storage-account/main.bicep --outfile ./out/storage.json
    $arm = Get-Content ./out/storage.json -Raw | ConvertFrom-Json
    $sa  = $arm.resources | Where-Object { $_.type -eq 'Microsoft.Storage/storageAccounts' }
  }

  It 'disables public blob access' {
    $sa.properties.allowBlobPublicAccess | Should -Be $false
  }

  It 'enforces TLS 1.2' {
    $sa.properties.minimumTlsVersion | Should -Be 'TLS1_2'
  }

  It 'exposes no secret outputs' {
    ($arm.outputs.PSObject.Properties.Value.value -join ' ') | Should -Not -Match 'listKeys'
  }
}

Run with Invoke-Pester -Path ./tests -CI, which sets a non-zero exit code on failure.

6. What-if analysis and deployment stacks for safe rollouts

Tests prove the template is correct. what-if proves what a specific deployment will do to a specific environment before it does it. Always preview before you apply:

az deployment group what-if \
  --resource-group rg-app-prod \
  --template-file consumer/main.bicep \
  --parameters consumer/prod.bicepparam

Read the symbols carefully: + create, - delete, ~ modify, = no change. A surprise - in a registry-module rollout almost always means a property changed identity (a name expression that now resolves differently), and it will orphan or recreate a resource. Stop and investigate before applying.

For lifecycle management, deploy through a deployment stack rather than a bare deployment. A stack tracks the set of resources it manages and can deny-delete or cleanly remove them, which prevents the classic failure mode where someone deletes a module from main.bicep and the underlying Azure resource silently lingers forever:

az stack group create \
  --name app-platform \
  --resource-group rg-app-prod \
  --template-file consumer/main.bicep \
  --parameters consumer/prod.bicepparam \
  --action-on-unmanage deleteResources \
  --deny-settings-mode denyDelete

--action-on-unmanage deleteResources means resources removed from the template get deleted from Azure (use detachAll if you want them left in place instead). --deny-settings-mode denyDelete blocks out-of-band deletion of stack-managed resources, so nobody can portal-click away a storage account that a versioned module owns. This pairs naturally with the registry: the stack pins exact module versions, and the deny settings keep the managed surface honest.

7. CI/CD publishing pipeline with semantic versioning and changelogs

The module repo and the consumer repos are separate concerns. The module repo’s job is: on merge to main, lint, test, derive the next semantic version, publish to ACR, and write a changelog. Here is the publishing pipeline in GitHub Actions, using OIDC federation so there are no stored secrets:

name: publish-bicep-modules
on:
  push:
    branches: [main]
permissions:
  id-token: write
  contents: write
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0          # full history for version derivation

      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Lint
        run: |
          for f in modules/*/main.bicep; do
            az bicep lint --file "$f" --diagnostics-format sarif
          done

      - name: Test (Pester)
        shell: pwsh
        run: Invoke-Pester -Path ./tests -CI

      - name: Determine version
        id: semver
        run: |
          # Derive next version from conventional-commit history.
          # semantic-release (or release-please) computes this; output a tag.
          echo "version=1.3.0" >> "$GITHUB_OUTPUT"

      - name: Publish changed modules
        run: |
          VER="${{ steps.semver.outputs.version }}"
          for dir in modules/*/; do
            mod=$(basename "$dir")
            [ "$mod" = "shared" ] && target="bicep/shared/types" || target="bicep/$mod"
            az bicep publish \
              --file "$dir/main.bicep" \
              --target "br:contosoplatform.azurecr.io/$target:$VER" \
              --with-source \
              --force
          done

Notes from running this in anger:

The Azure DevOps equivalent uses the AzureCLI@2 task with a workload-identity service connection and the identical az bicep publish commands; the only real difference is $(System.AccessToken) versus GitHub OIDC for auth.

8. Migrating from JSON ARM and interop with existing templates

You will not greenfield this. Most shops have years of ARM JSON. Bicep decompiles it:

az bicep decompile --file legacy/azuredeploy.json

Decompilation is a starting point, not a finished module. It produces correct but ugly Bicep: generic parameter names, no decorators, no typed shapes. Treat the output as a draft, then add @description/@allowed decorators, replace stringly-typed objects with exported types, and split monoliths into per-resource modules before publishing.

Interop runs both directions, which is what makes incremental migration safe:

The migration order that works: stand up the registry, publish your 5-10 highest-leverage patterns as clean Bicep modules first, point new workloads at them, and decompile-then-rewrite legacy templates opportunistically as you touch them. Do not attempt a big-bang rewrite of an existing ARM estate.

Verify

Run this end to end against a scratch resource group and registry to confirm the whole loop works:

# 1. Module compiles and lints clean (exit 0)
az bicep lint --file modules/storage-account/main.bicep --diagnostics-format sarif
echo "lint exit: $?"

# 2. Publish to ACR
az bicep publish \
  --file modules/storage-account/main.bicep \
  --target "br:contosoplatform.azurecr.io/bicep/storage-account:0.0.1-rc1" \
  --with-source --force

# 3. Artifact is queryable
az acr repository show-tags \
  --name contosoplatform --repository bicep/storage-account -o table

# 4. A consumer restores and builds against the registry
bicep restore consumer/main.bicep
bicep build consumer/main.bicep --stdout > /dev/null && echo "consumer build OK"

# 5. what-if previews cleanly (no unexpected deletes)
az deployment group what-if \
  --resource-group rg-app-dev \
  --template-file consumer/main.bicep \
  --parameters consumer/dev.bicepparam

If step 1 exits non-zero, a lint rule fired; fix it before publishing. If step 4 fails with a BCP restore error, your moduleAliases path or the published tag is wrong. If step 5 shows a - you did not expect, a resource identity changed between versions and you are about to recreate infrastructure.

Checklist

bicepazuremodule-registryacrci

Comments

Keep Reading