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.
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 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.
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.
| Step | What happens | Why 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
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.
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
}
}
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 choice | When to use it | Practical 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.mdin 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.
Common mistakes to avoid
| Mistake | Why it hurts | Better 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. |
