Learn a clean way to test and publish Azure Bicep modules with Azure DevOps. This guide explains why module testing matters, how to structure a module repository, how to run safe test deployments, and how to publish reusable Bicep modules to a private registry only after validation.

Azure BicepAzure DevOpsModule TestingPrivate RegistryChecked: 28 June 2026

Test and Publish Azure Bicep Modules with Azure DevOps

A practical guide for building a small Bicep module library that is tested before it is reused. The goal is simple: validate the module, run a real test deployment, clean up safely, and publish a versioned module to a private Bicep registry.

This is the missing layer between “I wrote a Bicep file” and “my team can safely reuse this module in production”.

Why test modules?Recommended flowRepository structureModule filesTest pipelinePublish pipelineVersioningCommon mistakesUseful links

Why test Bicep modules before publishing?

A Bicep module is reusable infrastructure code. If it is published too early, every consuming project can inherit the same mistake. Testing the module before publishing gives you a safer contract between the platform team and the teams that consume the module.

Build confidenceConfirm the module compiles and deploys before it becomes reusable.
Reduce production riskCatch parameter, naming, API version, RBAC, and dependency issues in a test resource group.
Protect consumersPublish only versioned modules that passed validation, what-if, deployment, and cleanup.
Simple idea: treat a Bicep module like an application package. You do not publish it to a shared registry until it has passed basic checks.

The clean module lifecycle

The process should be boring and repeatable. A developer changes a module, the pipeline validates it, a temporary deployment proves it works, and only then does a separate publish pipeline push a tagged version to the private registry.

StepWhat happensWhy it matters
1. Build Run az bicep build against the module and its test harness. Catches syntax and compile-time issues before Azure deployment starts.
2. What-if Run an ARM what-if deployment against a temporary resource group. Shows the predicted changes without modifying resources.
3. Test deploy Deploy the module through a small tests/main.bicep file. Proves the module can actually create resources with realistic parameters.
4. Validate Check that expected resources exist and basic properties are correct. Turns a deployment into a meaningful test instead of just “the command exited”.
5. Clean up Delete the temporary test resource group with condition: always(). Keeps the test environment clean and avoids unnecessary cost.
6. Publish Publish the tested module to Azure Container Registry using az bicep publish. Makes the module reusable by other Bicep files with a stable version tag.

Recommended repository structure

Keep the repository easy to understand. Each module should own its source file, metadata, documentation, and a test deployment.

modules/
  storage-account/
    main.bicep
    metadata.json
    README.md
    tests/
      main.bicep
      main.bicepparam

  key-vault/
    main.bicep
    metadata.json
    README.md
    tests/
      main.bicep
      main.bicepparam

pipelines/
  bicep-modules-ci.yml
  bicep-modules-publish.yml

scripts/
  Test-BicepModule.ps1
  New-BicepModuleReadme.ps1
Keep this simple: avoid a deeply nested enterprise structure until you really need it. A module folder, a metadata file, and a tests folder are enough for most teams.

What each module folder should contain

main.bicep

This is the reusable module. It should have clear parameters, useful outputs, sensible defaults, and minimal hidden assumptions. Microsoft’s Bicep best-practice guidance recommends clear parameter names and careful use of parameters for values that change between deployments.

tests/main.bicep

This is not the module itself. It is a small test harness that references the parent module and passes realistic values. It lets the pipeline deploy the module in isolation.

targetScope = 'resourceGroup'

module storage '../main.bicep' = {
  name: 'storageAccountModuleTest'
  params: {
    name: 'st${uniqueString(resourceGroup().id)}'
    location: resourceGroup().location
    skuName: 'Standard_LRS'
    allowBlobPublicAccess: false
  }
}

output storageAccountName string = storage.outputs.name

metadata.json

The registry tag should come from a simple version source. I prefer metadata.json because it is easy for a pipeline to parse and does not rely on fragile regex against the Bicep file.

{
  "name": "storage-account",
  "version": "1.0.0",
  "summary": "Deploys a secure Azure Storage Account baseline.",
  "owner": "cloud-platform",
  "minimumAzureCliVersion": "2.60.0"
}

Azure DevOps pipeline: validate, what-if, deploy, and clean up

This pipeline is intentionally practical. It does not try to publish modules. It only proves that a selected module builds and deploys into a temporary test resource group.

name: bicep-modules-ci-$(Date:yyyyMMdd)$(Rev:.r)

trigger:
  branches:
    include:
      - main
  paths:
    include:
      - modules/*
      - pipelines/bicep-modules-ci.yml

pr:
  branches:
    include:
      - main
  paths:
    include:
      - modules/*

parameters:
  - name: moduleName
    displayName: Bicep module folder to test
    type: string
    default: storage-account
    values:
      - storage-account
      - key-vault

variables:
  azureServiceConnection: sc-azure-platform-dev
  testLocation: australiaeast
  moduleFolder: modules/${{ parameters.moduleName }}
  testResourceGroup: rg-bicep-test-${{ parameters.moduleName }}-$(Build.BuildId)

pool:
  vmImage: ubuntu-latest

stages:
  - stage: Validate
    displayName: Validate Bicep module
    jobs:
      - job: BuildAndLint
        displayName: Build and lint
        steps:
          - checkout: self
          - task: AzureCLI@2
            displayName: Bicep build
            inputs:
              azureSubscription: $(azureServiceConnection)
              scriptType: bash
              scriptLocation: inlineScript
              inlineScript: |
                set -euo pipefail
                az bicep install
                az bicep build --file "$(moduleFolder)/main.bicep"
                az bicep build --file "$(moduleFolder)/tests/main.bicep"

  - stage: WhatIf
    displayName: Preview test deployment
    dependsOn: Validate
    jobs:
      - job: WhatIf
        steps:
          - checkout: self
          - task: AzureCLI@2
            displayName: Create temporary test resource group
            inputs:
              azureSubscription: $(azureServiceConnection)
              scriptType: bash
              scriptLocation: inlineScript
              inlineScript: |
                set -euo pipefail
                az group create --name "$(testResourceGroup)" --location "$(testLocation)"
          - task: AzureCLI@2
            displayName: What-if test deployment
            inputs:
              azureSubscription: $(azureServiceConnection)
              scriptType: bash
              scriptLocation: inlineScript
              inlineScript: |
                set -euo pipefail
                az deployment group what-if \
                  --resource-group "$(testResourceGroup)" \
                  --template-file "$(moduleFolder)/tests/main.bicep" \
                  --parameters "$(moduleFolder)/tests/main.bicepparam"

  - stage: DeployAndTest
    displayName: Deploy, validate, and clean up
    dependsOn: WhatIf
    jobs:
      - job: DeployValidateCleanup
        steps:
          - checkout: self
          - task: AzureCLI@2
            displayName: Deploy module test
            inputs:
              azureSubscription: $(azureServiceConnection)
              scriptType: bash
              scriptLocation: inlineScript
              inlineScript: |
                set -euo pipefail
                az deployment group create \
                  --resource-group "$(testResourceGroup)" \
                  --template-file "$(moduleFolder)/tests/main.bicep" \
                  --parameters "$(moduleFolder)/tests/main.bicepparam"
          - task: AzureCLI@2
            displayName: Basic post-deployment validation
            inputs:
              azureSubscription: $(azureServiceConnection)
              scriptType: bash
              scriptLocation: inlineScript
              inlineScript: |
                set -euo pipefail
                count=$(az resource list --resource-group "$(testResourceGroup)" --query "length(@)" -o tsv)
                echo "Resources deployed: $count"
                if [ "$count" -lt 1 ]; then
                  echo "No resources were deployed. Failing the test."
                  exit 1
                fi
          - task: AzureCLI@2
            displayName: Delete temporary test resource group
            condition: always()
            inputs:
              azureSubscription: $(azureServiceConnection)
              scriptType: bash
              scriptLocation: inlineScript
              inlineScript: |
                az group delete --name "$(testResourceGroup)" --yes --no-wait

Why this pipeline is structured this way

  • Manual module selection: useful when you have many modules and want to test one folder at a time.
  • Path filters: avoids running the pipeline when unrelated files change.
  • Build stage: catches Bicep compilation problems before Azure resources are touched.
  • What-if stage: previews the expected deployment changes before the real test deployment.
  • Temporary resource group: isolates test resources and makes cleanup simpler.
  • condition: always() cleanup: attempts cleanup even if validation fails.
Permission note: the service connection needs enough access to create and delete the temporary test resource group and deploy the resources used by the module. Do not give subscription Owner unless the module testing process genuinely requires it.

Azure DevOps pipeline: publish a tested module

Publishing should be separate from testing. Testing can run on pull requests or feature branches. Publishing should usually be manual or restricted to the main branch after review.

name: bicep-modules-publish-$(Date:yyyyMMdd)$(Rev:.r)
trigger: none

parameters:
  - name: moduleName
    displayName: Bicep module folder to publish
    type: string
    default: storage-account
    values:
      - storage-account
      - key-vault

variables:
  azureServiceConnection: sc-azure-platform-prod
  acrName: mybicepregistry
  acrLoginServer: mybicepregistry.azurecr.io
  moduleFolder: modules/${{ parameters.moduleName }}
  modulePathPrefix: bicep/modules

pool:
  vmImage: ubuntu-latest

stages:
  - stage: Publish
    displayName: Publish tested module to private registry
    jobs:
      - job: PublishModule
        steps:
          - checkout: self
          - task: AzureCLI@2
            displayName: Publish Bicep module
            inputs:
              azureSubscription: $(azureServiceConnection)
              scriptType: bash
              scriptLocation: inlineScript
              inlineScript: |
                set -euo pipefail
                module_name="${{ parameters.moduleName }}"
                module_file="$(moduleFolder)/main.bicep"
                metadata_file="$(moduleFolder)/metadata.json"

                version=$(python -c "import json; print(json.load(open('$metadata_file'))['version'])")
                repository="$(modulePathPrefix)/$module_name"
                target="br:$(acrLoginServer)/$repository:$version"

                existing_tags=$(az acr repository show-tags \
                  --name "$(acrName)" \
                  --repository "$repository" \
                  --query "[]" -o tsv 2>/dev/null || true)

                if echo "$existing_tags" | grep -qx "$version"; then
                  echo "Version $version already exists. Skipping publish."
                  exit 0
                fi

                az bicep publish --file "$module_file" --target "$target"
                echo "Published $target"

How a consuming Bicep file references the published module

module storage 'br:mybicepregistry.azurecr.io/bicep/modules/storage-account:1.0.0' = {
  name: 'storage'
  params: {
    name: 'st${uniqueString(resourceGroup().id)}'
    location: resourceGroup().location
    skuName: 'Standard_LRS'
    allowBlobPublicAccess: false
  }
}
Publishing rule: do not overwrite tags by default. If version 1.0.0 already exists, publish 1.0.1 or a new semantic version instead.

Versioning Bicep modules without making it weird

Versioning does not need to be complicated. The version is the registry tag. The important part is that consumers can choose a stable version and avoid surprise changes.

Versioning choiceWhen to use itPractical recommendation
Semantic versioning Shared enterprise modules used by several teams. Use tags like 1.0.0, 1.1.0, and 2.0.0.
Date-based version Internal modules where release sequence matters more than API semantics. Use tags like 2026.06.28, but document the convention.
Build number Temporary or pre-release modules. Useful for dev modules, but less friendly for long-term consumption.

Documentation: useful, but keep it separate

Your original pipeline generated README files and pushed them back to the repository. That can work, but it makes the pipeline more complex because it needs Git write permission, branch handling, commit logic, and token access.

My recommendation is to start simpler:

  • Keep README.md in each module folder.
  • Generate documentation in a separate optional documentation pipeline.
  • Do not mix documentation commits into the same pipeline that tests and publishes modules.
Why: module testing and module publishing are critical delivery steps. README generation is useful, but it should not make the core pipeline fragile.

Common mistakes to avoid

MistakeWhy it hurtsBetter approach
Publishing without a real test deployment The module may compile but still fail when deployed. Use a tests/main.bicep harness and deploy into a temporary resource group.
Using one permanent shared test resource group Old test resources can hide issues and create naming conflicts. Create a temporary resource group per run and delete it afterwards.
Using latest as the module tag Consumers can get unexpected changes. Use stable version tags and require consumers to choose a version.
Putting publish logic in the same PR validation pipeline A validation run can accidentally publish a module. Keep CI/test and publish pipelines separate.
Giving the service connection too much access A compromised pipeline can damage more than the test scope. Use least privilege and separate dev/test/publish service connections.

Final view: do not treat Bicep modules as loose snippets. Treat them as reusable infrastructure packages. Build them, run what-if, deploy them to a temporary environment, validate the result, clean up, and only then publish a versioned module to your private registry.