Terraform Lesson 103 of 137

Operating a Bicep Private Module Registry and Templating at Scale

In a nutshell

If you have ever installed an app from a company app-store, you already understand a Bicep private module registry. Someone builds a component once — a hardened storage account, a VNet with its subnets, a Key Vault wired for diagnostics — publishes a single versioned copy to a shared, trusted store, and from then on every team installs that exact copy instead of hand-rolling their own. Nobody re-writes the storage account; they pick storage-account:1.2.0 off the shelf and know it behaves the same in dev, test, and prod.

That shared store, for Bicep, is an Azure Container Registry (ACR). Bicep modules are pushed to it as OCI artifacts — the same packaging format container images use — and referenced with a br: (Bicep registry) path and a semantic-version tag. Publish 1.2.0 once, and a consumer three repositories away writes module storage 'br/platform:storage-account:1.2.0' and gets the byte-identical module. Fix a bug or harden a default, bump the version, and everyone upgrades on their own schedule — no copy-paste, no drift, no chasing a renamed parameter across N repositories by hand.

This lesson is the principal-level playbook for running that ecosystem: designing modules as stable APIs, publishing and versioning them on ACR, sharing types and functions, linting and testing the compiled output, previewing changes with what-if, and wiring the whole thing into a CI/CD pipeline that publishes on merge with no stored secrets. Beyond your own registry, we cover the public Azure Verified Modules (AVM) registry (br/public:) — the same idea run by Microsoft: a store of pre-hardened, Well-Architected building blocks you can consume without publishing anything yourself.

Level: Advanced · Time: ~34 min

Prerequisites

After this lesson you can

Bicep private module registry in ACR: publish → version → consume in CI

The diagram traces one module left to right — authored with a typed contract, published once as an immutable OCI artifact into ACR (or pulled from the public AVM registry), then pinned by every consumer through a bicepconfig.json alias and deployed by a CI pipeline that authenticates with OIDC and no stored secret.

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

Going deeper

The sections above are the working loop. This section is the layer underneath it — what az bicep publish actually pushes, how the public AVM registry fits, when a template spec is the better tool, how to harden the registry itself, and the exact identity plumbing that lets CI publish and consume without a stored secret. Read it once you have the basic loop working.

The OCI artifact: what az bicep publish actually pushes

az bicep publish is not a bespoke upload. It compiles your .bicep to ARM JSON, then packages that JSON as an OCI artifact — a manifest plus one or more layers with Bicep-specific media types (for example application/vnd.ms.bicep.module.artifact) — and pushes it to ACR over the standard OCI distribution API, exactly as a container image would be pushed. That is why any ACR works and why there is no “Bicep SKU”: to the registry, your module is just another artifact addressed by a repository name and a tag.

--with-source adds an extra layer containing the original .bicep (and any locally-referenced files), so the language service can show real source in “go to definition” and you can recover a module whose source repo is lost. Without it, consumers only ever see the compiled ARM.

The registry stores every artifact by content digest (a sha256:... hash); a tag like :1.2.0 is merely a mutable pointer to a digest. Inspect what is really there:

# List manifests with their digests and the tags pointing at them
az acr manifest list-metadata \
  --registry contosoplatform \
  --name bicep/storage-account \
  --output table

This digest-vs-tag distinction is the whole reason “immutable tags” matter: unless you lock a tag, someone can --force-republish a different digest under :1.2.0, and the two environments that already cached the old digest now silently diverge from a fresh consumer that pulls the new one.

Azure Verified Modules (AVM) and the public registry br/public:

Before you write a storage-account module, check whether Microsoft already ships one. Azure Verified Modules (AVM) is Microsoft’s curated, Well-Architected module library, published to a public registry (Microsoft Container Registry) and reachable through Bicep’s built-in br/public: alias — no bicepconfig.json entry required.

module sa 'br/public:avm/res/storage/storage-account:0.19.0' = {
  name: 'sa'
  params: {
    name: 'st${uniqueString(resourceGroup().id)}'
    skuName: 'Standard_ZRS'
    // AVM resource modules default to hardened settings (TLS1.2, no public blob).
  }
}

Pin whatever tag is current — check the module’s page on the AVM site, because versions advance quickly and each module is versioned independently. The naming convention tells you what you are getting:

Prefix Kind Contains
avm/res/<provider>/<resource> Resource module One primary resource, hardened (e.g. avm/res/storage/storage-account)
avm/ptn/<pattern> Pattern module A multi-resource architecture (e.g. a hub-spoke, an AKS landing zone)
avm/utl/<utility> Utility module Cross-cutting helpers (types, role definitions)

AVM does not replace a private registry — it complements one. Two patterns work well: consume AVM modules directly where the defaults suit you, or wrap an AVM module in a thin org module that sets your mandatory defaults (naming, tags, diagnostic settings) and publish that to your private registry under your own namespace. Either way, pin exact versions; AVM modules follow semver and change like any other dependency. (AVM also ships Terraform modules, so the same building blocks are available if part of your estate is Terraform.)

Template specs vs private registry vs AVM — when to use which

There are three ways to share a reusable Azure deployment artifact. They are not interchangeable:

Template spec ACR private registry AVM (br/public:)
What it stores An ARM/Bicep template as a first-class Azure resource Any Bicep/ARM module as an OCI artifact Microsoft-maintained Bicep/Terraform modules
Addressing Resource ID + version br:<registry>/<path>:<tag> br/public:avm/...:<tag>
Versioning Named versions on the resource OCI tags (immutable if locked) SemVer tags
Access control Azure RBAC on the resource AcrPush/AcrPull on the registry Public, anonymous read
Source visibility Template only Compiled ARM + optional --with-source Public source on GitHub
Best for Sharing a whole deployable template across subscriptions in one tenant A versioned library of building blocks consumed by many repos Not reinventing hardened primitives

Rule of thumb: template specs shine when the unit of sharing is a complete, deployable template (a governance team publishes “the approved landing-zone deployment” and app teams deploy it as-is). An ACR registry shines when the unit of sharing is a library of composable modules that consumers assemble into their own templates. AVM is the primitive layer under both — build your registry modules on top of AVM rather than from scratch.

Hardening the registry itself

A registry that gates production infrastructure deserves the same care as the infrastructure. On a Premium ACR:

# Lock a released tag so it can never be overwritten or deleted
az acr repository update \
  --name contosoplatform \
  --image bicep/storage-account:1.2.0 \
  --write-enabled false \
  --delete-enabled false

# Take the registry off the public internet; consumers/CI reach it via Private Link
az acr update --name contosoplatform --public-network-enabled false

# Geo-replicate so restore is local (and resilient) in each consumer region
az acr replication create --registry contosoplatform --location westeurope

# Purge accumulated untagged manifests (old digests orphaned when a tag moved)
az acr config retention update \
  --registry contosoplatform --status enabled --days 30 --type UntaggedManifests

A few caveats. Standard ACR is enough to publish and consume; the hardening above (tag locks, Private Link, geo-replication, retention policy) is Premium-tier. Never purge tagged, released versions — a consumer somewhere is pinned to them; retention should only reap untagged orphans. And if you disable public network access, your CI agents need a route in: a private endpoint on a self-hosted agent’s network, or ACR’s “trusted Azure services” allowance for Microsoft-hosted flows.

Managed identity in CI: the exact auth chain

The GitHub Actions pipeline in section 7 uses OIDC, and it is worth understanding precisely why there is no secret. The pipeline’s identity is an Entra ID (formerly Azure AD) app registration — or a user-assigned managed identity — carrying a federated identity credential that trusts the CI provider’s OIDC issuer for one specific subject:

az ad app federated-credential create \
  --id <APP_OBJECT_ID> \
  --parameters '{
    "name": "gh-main",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:contoso/bicep-modules:ref:refs/heads/main",
    "audiences": ["api://AzureADTokenExchange"]
  }'

At runtime the runner asks GitHub for a short-lived OIDC token whose subject claim is repo:contoso/bicep-modules:ref:refs/heads/main. azure/login presents it to Entra ID, which — seeing a federated credential that matches that exact subject — returns an access token. RBAC then decides what that token can do: AcrPush on the registry to publish, and on the consumer side AcrPull to restore. Only non-secret client/tenant/subscription IDs are stored anywhere; there is nothing to rotate or leak. This is strictly better than --admin-enabled true (a shared static password) or a service-principal client secret (a long-lived credential in a variable group).

One subtlety that trips people up: who needs AcrPull? The identity that runs bicep restore/bicep build — your CI agent — needs it, because restore is what pulls the module from ACR. The build inlines the restored module into the compiled ARM JSON, so at deploy time Azure Resource Manager never talks to the registry at all. The deployment principal therefore needs deployment rights (for example Contributor on the target scope) but not AcrPull. Granting the deploy identity AcrPull in the belief that ARM pulls the module is a common and harmless-looking misconfiguration that hides the real dependency.

Restore, caching, and offline builds

bicep restore downloads a referenced registry module into a local cache — under ~/.bicep on Linux/macOS (%USERPROFILE%\.bicep on Windows), keyed by registry, repository, and digest. Because the entry is keyed by digest, a pinned version is byte-identical on every machine and every rebuild. bicep build then reads from that cache and inlines the module; it does not hit the network if the cache is warm.

This enables deterministic and air-gapped builds. Restore on a connected agent, cache the ~/.bicep directory (a CI cache key on the module set works well), and subsequent builds run offline against the cached digests. bicep restore --force refreshes the cache when you deliberately want to re-pull. In ephemeral CI, always restore explicitly before build — a fresh agent has an empty cache, and an implicit restore inside build can mask a wrong alias or a missing AcrPull as a confusing build failure.

Testing the compiled output with PSRule for Azure

Section 5 covers ARM-TTK and hand-written Pester. The modern, higher-coverage option is PSRule for Azure — a PowerShell rules engine with hundreds of Well-Architected checks that can expand your .bicep to ARM and evaluate the result, so you test the same JSON that will deploy. Configure it with a ps-rule.yaml at the repo root:

# ps-rule.yaml
configuration:
  # Let PSRule expand .bicep to ARM before evaluating rules (needs az bicep on PATH).
  AZURE_BICEP_FILE_EXPANSION: true
input:
  pathIgnore:
    - '**/*.md'
    - '**/*.bicepparam'
output:
  culture:
    - 'en-US'

Run it locally, or add a step to CI that emits SARIF into code scanning:

- name: Analyze with PSRule for Azure
  uses: microsoft/ps-rule@v2
  with:
    modules: PSRule.Rules.Azure
    inputPath: modules/
    outputFormat: Sarif
    outputPath: reports/ps-rule.sarif

PSRule complements, rather than replaces, your own Pester assertions: PSRule enforces Microsoft’s Well-Architected best practices broadly, while your Pester tests encode the guardrails specific to your org (“every account is Standard_ZRS or better”, “diagnostic settings point at the platform Log Analytics workspace”). Gate the pipeline on both.

Azure DevOps: the publish equivalent

The section-7 pipeline is GitHub Actions; the Azure DevOps shape is a one-to-one translation using the AzureCLI@2 task and a workload-identity federation service connection (the ADO equivalent of OIDC — again, no stored secret):

# azure-pipelines.yml — Azure DevOps publish equivalent
trigger:
  branches:
    include: [ main ]
pool:
  vmImage: ubuntu-latest
steps:
  - task: AzureCLI@2
    inputs:
      azureSubscription: 'sc-platform-bicep'   # workload-identity service connection
      scriptType: bash
      scriptLocation: inlineScript
      inlineScript: |
        az bicep publish \
          --file modules/storage-account/main.bicep \
          --target "br:contosoplatform.azurecr.io/bicep/storage-account:$(version)" \
          --with-source --force

The az bicep publish invocation is identical to GitHub Actions; only the auth wrapper and the variable syntax ($(version) vs ${{ }}) differ.

Practice challenges

Work these against a scratch resource group and a throwaway ACR. They escalate from beginner to advanced. Each has a worked solution with a one-line “why”.

<details> <summary><strong>Challenge 1 (Beginner):</strong> Create a private registry the right way, and grant push access without the admin account.</summary>

# Create ACR with the admin account OFF
az acr create \
  --resource-group rg-platform-bicep \
  --name contosoplatform --sku Standard --admin-enabled false

# Grant the publishing service principal AcrPush (scoped to the registry)
ACR_ID=$(az acr show --name contosoplatform --query id -o tsv)
az role assignment create \
  --assignee <PUBLISHER_SP_OBJECT_ID> \
  --role AcrPush --scope "$ACR_ID"

Why: AcrPush via Entra RBAC is auditable and scoped; the admin account is a single shared static password you never want gating production modules. </details>

<details> <summary><strong>Challenge 2 (Beginner):</strong> Publish a module with its source embedded, then prove the tag exists.</summary>

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

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

Why: --with-source embeds the original .bicep so tooling (and future you) can read real code, not just compiled ARM; listing tags confirms the artifact landed under the version you intended. </details>

<details> <summary><strong>Challenge 3 (Intermediate):</strong> Add a bicepconfig.json alias, consume the module through it, and explain why :latest would be unsafe here.</summary>

// bicepconfig.json (consumer repo root)
{
  "moduleAliases": {
    "br": {
      "platform": { "registry": "contosoplatform.azurecr.io", "modulePath": "bicep" }
    }
  }
}
module storage 'br/platform:storage-account:1.0.0' = {
  name: 'app-storage'
  params: { name: 'stappdata${uniqueString(resourceGroup().id)}' }
}

Why: the alias means a registry move is a one-line edit, not an estate-wide find-and-replace; pinning :1.0.0 (not :latest) guarantees every environment resolves the same immutable digest, so prod and dev cannot silently diverge. </details>

<details> <summary><strong>Challenge 4 (Intermediate):</strong> Consume a Microsoft-maintained Azure Verified Module from the public registry and build it offline.</summary>

// consumer/main.bicep
module sa 'br/public:avm/res/storage/storage-account:0.19.0' = {
  name: 'avm-sa'
  params: { name: 'st${uniqueString(resourceGroup().id)}' }
}
bicep restore consumer/main.bicep     # pulls AVM module into ~/.bicep cache
bicep build   consumer/main.bicep --stdout > /dev/null && echo "built offline OK"

Why: br/public: is Bicep’s built-in alias for AVM, so no bicepconfig.json entry is needed; restore hydrates the digest-keyed cache so the subsequent build is deterministic and network-free. (Pin whatever the current AVM tag is.) </details>

<details> <summary><strong>Challenge 5 (Advanced):</strong> Wire OIDC federation and publish only the modules that changed in the last commit.</summary>

# Trust GitHub's OIDC issuer for the main branch of this repo (no secret stored)
az ad app federated-credential create --id <APP_OBJECT_ID> --parameters '{
  "name": "gh-main",
  "issuer": "https://token.actions.githubusercontent.com",
  "subject": "repo:contoso/bicep-modules:ref:refs/heads/main",
  "audiences": ["api://AzureADTokenExchange"]
}'
# In CI: publish only directories that changed since the previous commit
VER="${{ steps.semver.outputs.version }}"
git diff --name-only HEAD~1 HEAD | awk -F/ '/^modules\//{print $2}' | sort -u | while read mod; do
  az bicep publish \
    --file "modules/$mod/main.bicep" \
    --target "br:contosoplatform.azurecr.io/bicep/$mod:$VER" \
    --with-source --force
done

Why: the federated credential removes any stored secret (the token is minted per run and scoped to repo:...:ref:refs/heads/main); publishing only changed modules avoids bumping versions on modules that did not change, keeping each module’s history clean. </details>

<details> <summary><strong>Challenge 6 (Advanced):</strong> Make a released version un-overwritable AND add a policy gate that fails if a storage module ever allows public blob access.</summary>

# Lock the released tag: no overwrite, no delete (Premium ACR)
az acr repository update \
  --name contosoplatform --image bicep/storage-account:1.2.0 \
  --write-enabled false --delete-enabled false
# Guardrail test over the compiled ARM (runs in CI, fails the build on regression)
Describe 'storage guardrails' {
  It 'never allows public blob access' {
    bicep build ./modules/storage-account/main.bicep --outfile ./out/sa.json
    $sa = (Get-Content ./out/sa.json -Raw | ConvertFrom-Json).resources |
      Where-Object { $_.type -eq 'Microsoft.Storage/storageAccounts' }
    $sa.properties.allowBlobPublicAccess | Should -Be $false
  }
}

Why: defense in depth — the tag lock means the artifact for 1.2.0 can never be swapped out from under consumers, and the CI assertion means a new version can never regress the public-blob guardrail before it is ever published. </details>

Common beginner mistakes

Glossary

bicepazuremodule-registryacrci
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments