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:
- Decorators are contract, not decoration.
@minLength,@maxLength,@allowed, and@descriptionare validated at compile time, surface inwhat-if, and render in the portal’s custom-deployment UI. A consumer who passes a bad value gets a clear error at build time instead of an opaque ARM failure at deploy time. - Secure inputs are marked
@secure(). Any password, key, or connection string parameter must carry@secure()so it never lands in deployment history or logs. Thesecure-parameter-defaultanalyzer rule (below) will fail the build if you give a@secure()param a default literal. - Outputs never carry secrets. ARM deployment outputs are stored and readable by anyone with reader on the deployment. Emit resource IDs and names; never emit
listKeys()results.
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:
- Tags are immutable in practice. Never re-publish a different artifact under an existing version tag. Consumers cache by digest; a mutated tag means two environments deploying “1.2.0” get different infrastructure. Enable ACR’s lock or a policy that blocks overwrites in production registries, and reserve
--forcefor pre-release tags only. - Pin exact versions in production, float only in dev.
:1.2.0is reproducible. There is no:^1.2range operator in Bicep, so “floating” means a mutable tag like:latestor:1, which you should use only for fast-moving internal modules in non-prod. Production deployments pin to an exact immutable version.
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:
- Derive the version, never type it. Use
semantic-releaseor Google’srelease-pleaseto compute the next version from conventional-commit messages (feat:-> MINOR,fix:-> PATCH,feat!:/BREAKING CHANGE-> MAJOR). Both tools also generate theCHANGELOG.md, which is your consumers’ upgrade guide. - Only
--forceis acceptable on a fresh, just-computed version. Because the tag is newly derived per merge, the overwrite never clobbers an existing release. If you ever publish a static tag (:latest,:1), keep that as a separate step pointing at the same digest. - Publish per changed module, not the whole catalog, once your catalog is large. Diff against the previous commit and publish only the directories that changed, so unrelated modules keep their existing versions.
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:
- Bicep can consume ARM JSON directly. A
modulereference can point at a.jsonARM template, so a half-migrated estate works: new Bicep orchestration calling legacy JSON modules that have not been rewritten yet. az bicep publishaccepts ARM JSON as input. You can publish a.jsontemplate to the registry under a versioned tag and consume it as abr:module, which lets you put existing ARM behind the same registry interface while you rewrite it.- Everything compiles to ARM anyway. Bicep is a transparent transpiler; the deployment that hits Azure Resource Manager is ARM JSON. There is no runtime to adopt and nothing to roll back to if you change your mind about a module.
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.