At a glance

For: platform engineers who keep shared Bicep modules and landing zone code in one repo · Setup time: about 10 minutes to run every check offline; the Azure path needs a tenant admin · Stack: Bicep CLI 0.48.1, Azure CLI 2.61 or later (deployment stacks), GitHub Actions, Azure Container Registry, Bash and jq
Last verified: 2026-10-20 (offline checks and script tests; the Azure path hasn't run against a tenant yet)

At the end of this, every component in your Bicep repo has one version, every consumer pins an exact one, and a pull request that changes a module and its consumers builds in one run, before anything is published. Every configuration deploys as one deployment stack: you see what changed since its last successful deployment, then what-if and stack what-if, then you approve.

These patterns are for Bicep repos at enterprise scale, where several teams share modules and one change can reach many environments. The starter shows them on GitHub Actions, around a small landing zone in three tiers: bicep-platform-starter. Everything a pull request checks has run there. The Azure path (publish, preview, deploy) is tested against a fake az, and the README lists what still needs a first run in a real tenant.

Every change reaches Azure as a pinned version, previewed, approved and deployed as a stack
Every change reaches Azure as a pinned version, previewed, approved and deployed as a stack

The problem: registry modules ship untested

Once modules live in a registry, every consumer pins a version: br/platform:modules/network/subnet:v1.2.0. That's the point, and it creates the first problem. To use a new subnet module in a solution, the module has to be in the registry first. So the module goes into one pull request, gets merged and published, and the solution follows in a second one. The module was published before anything deployed it.

Most teams then take one of two shortcuts. Relative paths (../../modules/subnet/main.bicep) build in one pull request, but every consumer gets every change the moment it merges, and a version means nothing. Or the team publishes release candidates from branches and plans to clean the registry up later.

The second problem is the preview. What-if compares the template with what's in Azure right now. It doesn't say what changed since the last deployment that worked, and it can't show what a deployment stack would delete because a resource left the template. The third is documentation: a README written by hand is out of date by the second release. And as agents write more of the Bicep, each of these rules has to hold for code nobody on the team typed.

Steps: a versioned landing zone in 10 steps

Steps 1 to 6 run on your machine and in every pull request, with no Azure account. Steps 7 to 9 need Azure; the starter switches them off until you set the repository variable AZURE_ENABLED. Step 10 puts the rules where coding agents read them.

1. Cut solutions along tiers and lifecycles

A solution is everything that's created and deleted together, and it deploys as one deployment stack per environment. A deployment stack is an Azure resource that owns what a template created: when a resource leaves the template, the stack deletes or detaches it, and deleting the stack removes everything it owns. So one stack is one lifecycle.

The test I use is a disaster. If the environment were gone tomorrow, in which order would it come back? That order goes into the folder name, <tier>-<NNN>-<name>:

Tier Solution Stack at Runs
00-init 000-bootstrap (a script), 010-module-registry tenant, management subscription once, by an admin, before the pipeline can
01-core 000-management-groups tenant root group deployment 1: the groups and where each subscription sits
010-platform contoso-platform deployment 2: hub, Entra Domain Services, Log Analytics, Sentinel
02-workloads 000-sample-app its own subscription one application each, in parallel

The core is two stacks that run one after the other. Management groups and subscription placements live as long as the tenant; the platform changes every month. A single template with stages and flags would give both one lifecycle, so they get two stacks with different settings: the groups detachAll, because no template edit should ever delete a management group, the platform deleteAll. Run one after the other, they rebuild an environment, and step 9 turns that into a workflow.

The layout follows the Azure landing zone of the Cloud Adoption Framework: a Platform group with Security, Management, Connectivity and Identity, each with its own subscription, and Landing zones with Corp and Online. Identity runs Microsoft Entra Domain Services, a managed domain, instead of domain controller VMs to patch. Dev gets a hierarchy of its own, contoso-canary, to try changes on, and no managed domain: a tenant can have only one.

platform.json decides where each configuration deploys, so nobody types a subscription ID into a workflow:

"01-core-000-management-groups": {
  "actionOnUnmanage": "detachAll",
  "whatIf": false,
  "targets": {
    "dev": { "tenantRootGroup": true },
    "prod": { "tenantRootGroup": true }
  }
},
"01-core-010-platform": {
  "targets": {
    "dev": { "managementGroup": "contoso-canary-platform" },
    "prod": { "managementGroup": "contoso-platform" }
  }
},
"02-workloads-000-sample-app": {
  "targets": {
    "dev": { "subscription": "sampleApp" },
    "prod": { "subscription": "sampleApp" }
  }
}

The configurations read their subscription IDs from the same file with loadJsonContent('../../../platform.json'), so there's one place for them.

A stack owns only what it creates. The workload's spoke network peers with the hub, which the platform stack owns. A peering has two halves, one on each network, and the workload's stack creates both, so retiring the workload removes its peering from the hub too:

module mPeeringFromHub 'br/platform:modules/network/peering:v1.0.0' = {
  scope: resourceGroup(pHub.subscriptionId, pHub.resourceGroupName)
  params: {
    pVnetName: pHub.name
    pRemoteVnetId: mVnet.outputs.oId
  }
}
The spoke's stack owns both halves of its peering; the hub only knows it exists
The spoke's stack owns both halves of its peering; the hub only knows it exists

2. Pin every component to one version

Three layers build on each other. types/ holds shared types and the naming function, modules/ one resource or a small composite (one platform subscription's content), solutions/ the lifecycles. Each component keeps its version in three places that must agree: metadata.json, metadata version in main.bicep, and the top entry of CHANGELOG.md. Every reference names an exact version, with no ranges and no latest:

import { contextType, hubVnetType, resourceName } from 'br/platform:types:v1.0.0'

module mVnet 'br/platform:modules/network/vnet:v1.0.0' = {
  scope: rRg
  params: {
    pContext: pContext
    pName: 'sampleapp'
    pAddressPrefixes: [pAddressPrefix]
  }
}

Names come from one function in types/: resourceName('vnet', 'hub', pContext, 1) returns vnet-hub-prod-szn-001, the Cloud Adoption Framework order (type, workload, environment, region, instance) with a short region code.

A change travels up by hand. In the starter's second commit, modules/storage/account 1.1.0 adds blob containers. The sample app pins it and becomes 1.1.0 itself. Dev's configuration moves to the new solution version; prod stays on the old one until someone moves it:

// solutions/02-workloads-000-sample-app/configurations/dev.bicepparam
using 'br/platform:solutions/02-workloads-000-sample-app:v1.1.0'
// solutions/02-workloads-000-sample-app/configurations/prod.bicepparam
using 'br/platform:solutions/02-workloads-000-sample-app:v1.0.0'

An environment changes when its configuration changes, and only then. The bump-version skill in the repo has the table for patch, minor and major.

Exact pins from the shared types up to each configuration; prod stays one version behind dev
Exact pins from the shared types up to each configuration; prod stays one version behind dev

3. Build unpublished versions in the same PR

In CI, the starter points every registry reference at local files before building, and puts them back afterwards. scripts/convert-registry-refs.sh resolves each br/platform:<component>:v<version> in this order:

  1. The version on this branch: <component>/metadata.json has it, so the reference becomes a relative path to the branch's source.
  2. An older version with a release tag <component>/v<version>: git archive unpacks that tag into .refcache/, and the reference points there.
  3. Anything else fails: a typo, or a component that changed without a version bump.
resolve() {
  local path=$1 version=$2 tag
  if [ -f "$ROOT/$path/metadata.json" ] && [ "$(version_of "$path")" = "$version" ]; then
    RESOLVED="$ROOT/$path/main.bicep"
    FROM="source on this branch"
    return 0
  fi
  tag=$(tag_of "$path" "$version")
  if tag_exists "$tag"; then
    materialize "$path" "$version" "$tag"
    RESOLVED="$ROOT/.refcache/$tag/$path/main.bicep"
    FROM="release tag \`$tag\`"
    return 0
  fi
  return 1
}

The pull request that adds storage 1.1.0 and moves the sample app to it builds in one run. The job summary shows where each reference came from:

| solutions/02-workloads-000-sample-app/main.bicep | br/platform:modules/storage/account:v1.1.0 | source on this branch |
| .../configurations/dev.bicepparam  | br/platform:solutions/02-workloads-000-sample-app:v1.1.0 | source on this branch |
| .../configurations/prod.bicepparam | br/platform:solutions/02-workloads-000-sample-app:v1.0.0 | release tag solutions/02-workloads-000-sample-app/v1.0.0 |

Nothing the script changes is committed: --restore puts every reference back, and the tests check that git diff is empty afterwards. The release tags are an offline registry, so CI never needs Azure to build an older version.

convert-registry-refs.sh
#!/usr/bin/env bash
# Point registry references at local files, in the working copy only, so a pull request can build
# component versions that aren't released yet. Nothing this script changes is ever committed.
#
# Each br/platform:<component>:v<version> reference resolves to:
#   1. the source on this branch, if <component>/metadata.json has that version
#   2. the released source, if the git tag <component>/v<version> exists (unpacked into .refcache/)
#   3. an error otherwise: a typo, or a component that changed without a version bump
#
# Usage:
#   scripts/convert-registry-refs.sh [--state FILE] [FILE...]   convert (default: every committed file)
#   scripts/convert-registry-refs.sh --restore FILE             put the references back
set -euo pipefail
# shellcheck source=lib.sh
. "$(dirname "$0")/lib.sh"

state=""
restore=""
files=()
while [ $# -gt 0 ]; do
  case "$1" in
    --state) state=$2; shift 2 ;;
    --restore) restore=$2; shift 2 ;;
    -h|--help) sed -n '2,13p' "$0"; exit 0 ;;
    *) files+=("$1"); shift ;;
  esac
done

replace_in_file() { # <file> <from> <to>: replace one quoted string, literally
  local file=$1 from=$2 to=$3 content
  content=$(cat "$file"; printf x)
  content=${content%x}
  content=${content//"'$from'"/"'$to'"}
  printf '%s' "$content" > "$file"
}

if [ -n "$restore" ]; then
  [ -f "$restore" ] || exit 0
  while IFS=$'\t' read -r file from to; do
    if [ -f "$file" ]; then replace_in_file "$file" "$to" "$from"; fi
  done < "$restore"
  rm -f "$restore"
  exit 0
fi

converted=0
summary "| File | Reference | Built from |"
summary "|---|---|---|"

# materialize <component> <version> <tag>: unpack a released version and convert its references too
materialize() {
  local path=$1 tag=$3 dest="$ROOT/.refcache/$3" f
  [ -f "$dest/.done" ] && return 0
  rm -rf "$dest"
  mkdir -p "$dest"
  git -C "$ROOT" archive "refs/tags/$tag" "$path" | tar -x -C "$dest"
  for f in $(cd "$dest" && find . -name '*.bicep' | sed 's|^\./||'); do
    convert_file "$dest/$f"
  done
  touch "$dest/.done"
}

# resolve <component> <version>: sets RESOLVED (absolute path) and FROM, or returns 1
resolve() {
  local path=$1 version=$2 tag
  if [ -f "$ROOT/$path/metadata.json" ] && [ "$(version_of "$path")" = "$version" ]; then
    RESOLVED="$ROOT/$path/main.bicep"
    FROM="source on this branch"
    return 0
  fi
  tag=$(tag_of "$path" "$version")
  if tag_exists "$tag"; then
    materialize "$path" "$version" "$tag"
    RESOLVED="$ROOT/.refcache/$tag/$path/main.bicep"
    FROM="release tag \`$tag\`"
    return 0
  fi
  return 1
}

convert_file() {
  local file=$1 dir ref path version rel shown
  dir=$(cd "$(dirname "$file")" && pwd -P)
  shown=${file#"$ROOT"/}
  for ref in $(registry_refs "$file"); do
    path=${ref#br/"$ALIAS":}
    version=${path##*:v}
    path=${path%:v*}
    if ! resolve "$path" "$version"; then
      error "$shown" "$ref is neither the version on this branch ($(version_of "$path" || true)) nor a released tag. A typo, or a change without a version bump?"
      continue
    fi
    rel=$(relpath "$dir" "$RESOLVED")
    replace_in_file "$file" "$ref" "$rel"
    case "$shown" in
      .refcache/*) ;;
      *)
        converted=$((converted + 1))
        echo "$shown: $ref -> $rel"
        summary "| \`$shown\` | \`$ref\` | $FROM |"
        if [ -n "$state" ]; then printf '%s\t%s\t%s\n' "$file" "$ref" "$rel" >> "$state"; fi
        ;;
    esac
  done
}

if [ ${#files[@]} -eq 0 ]; then
  for f in $(bicep_files); do files+=("$ROOT/$f"); done
fi
for f in ${files[@]+"${files[@]}"}; do
  case "$f" in /*) ;; *) f="$PWD/$f" ;; esac
  convert_file "$f"
done

echo "$converted reference(s) now point at local files."
finish
Before: module, publish, then consumer, in three pull requests. After: one pull request, built from local files
Before: module, publish, then consumer, in three pull requests. After: one pull request, built from local files

4. Gate every pull request on versions

scripts/check-versions.sh runs on every pull request and again before every publish. It fails when:

  • metadata.json, metadata version and the top changelog entry disagree
  • a version is lower than the newest release
  • a released version's .bicep files changed since its tag
  • a configuration pins a version of its solution that is neither released nor on the branch
  • a solution folder isn't <tier>-<NNN>-<name>, a configuration has no target in platform.json, or the solution's targetScope doesn't match its target
  • an init solution uses the registry, or any other solution doesn't

Without the third rule, a change to a released component would merge, and the publish step would skip the version because it already exists:

ERROR modules/network/nsg/metadata.json: 1.0.0 is already released (tag modules/network/nsg/v1.0.0), but modules/network/nsg/main.bicep changed since. Bump the version.

pr.yml runs this check, build.sh, the README and snapshot checks and the scripts' own tests in parallel jobs. One more job, gate, depends on all of them and is the only required check, because a required check that a path filter skips blocks the merge for good.

5. Generate READMEs from the compiled template

Every component's README is generated from its compiled ARM JSON, the output of bicep build, by a jq filter, scripts/readme.jq. The compiler already knows what a reader needs: which parameters are required, their types, defaults, allowed values and limits. Descriptions come from @description, examples from @metadata:

@description('Address space of the spoke. The app subnet gets the first /26.')
@metadata({ example: '10.10.16.0/24' })
param pAddressPrefix string
### pAddressPrefix

Address space of the spoke. The app subnet gets the first /26.

- Type: string
- Example: `10.10.16.0/24`

update-readme.sh --check regenerates every README into a temporary folder and fails on any difference. That turns the README into a deterministic check: a changed description, a new parameter or a changed default fails the pull request until the regenerated README is committed.

A second filter, describe-lint.jq, reads the same JSON and fails on a missing description, a description that lists an example or a default, a hand-written badge, and a symbol without its prefix: parameters p, variables v, resources r, modules m, outputs o. The compiled template names every symbol, so the naming rule needs no Bicep parser:

ERROR modules/network/app-network/main.bicep: parameter serviceEndpoints needs the prefix p
ERROR modules/network/app-network/main.bicep: a description lists an example or a default: use @metadata({ example: … }); defaults come from the template

6. Commit a snapshot for every configuration

bicep snapshot resolves a .bicepparam into the resources it would create, offline: names, IDs and properties, with the parameters filled in. scripts/check-snapshots.sh runs it for every configuration at the stack's own scope and name, and commits the result next to the configuration as <env>.snapshot.json:

bicep snapshot solutions/02-workloads-000-sample-app/configurations/dev.bicepparam --mode validate \
  --subscription-id "$TARGET_ID" --tenant-id "$TENANT_ID" --location switzerlandnorth \
  --deployment-name 02-workloads-000-sample-app-dev

A pull request then shows what a change does to each environment, next to the change in the template. This is the dev snapshot diff of the storage 1.1.0 change. Prod's snapshot didn't change, because prod still pins 1.0.0:

+    {
+      "type": "Microsoft.Storage/storageAccounts/blobServices/containers",
+      "name": "stsampleappdevlqqlvt/default/uploads",
+      "apiVersion": "2026-06-01",
+      "properties": {
+        "publicAccess": "None"
+      }
+    }

In CI the check fails when a snapshot is out of date. --update writes new ones; you read the diff and commit it. --against <commit> compares with the snapshots of any commit, which step 8 uses.

7. Publish immutable versions on merge

publish.yml runs on every merge to main. It runs the version check again, then tags every version that has no tag yet as <component>/v<version>. With Azure on, it publishes each new version to the registry and locks it:

bicep publish "$ROOT/$c/main.bicep" --target "br:$server/$image"
az acr repository update --name "$acr" --subscription "$sub" --image "$image" \
  --write-enabled false --delete-enabled false

A locked version can't be overwritten or deleted, so v1.0.0 means the same thing next year. A tag ruleset on GitHub does the same for the release tags. Init solutions get a tag but are never published, because they run before the registry exists. The registry itself is 00-init-010-module-registry: a self-contained solution (using '../main.bicep', no registry references) that an admin deploys once.

8. Preview with what-if and stack what-if

deploy.yml starts by hand from main: pick a configuration, then deploy or retire. Before anyone approves, the run shows three things.

What changed since the last successful deployment. After each deployment, record-deployment.sh writes a GitHub deployment with the commit and the task stack:<name>. The next run finds that commit and compares the snapshots:

last=$(scripts/find-last-deployment.sh "$ENVIRONMENT" "$STACK")
scripts/check-snapshots.sh --against "${last%% *}" "solutions/${CONFIG%%/*}/configurations/$ENVIRONMENT.bicepparam"

That diff says what this deployment changes compared with the last one that worked. It's a different question from what's in Azure right now, where someone may have clicked something since.

What-if answers that second question: which properties change on the resources in the template, compared with Azure.

Stack what-if shows what the stack does to everything it manages, including what it would delete or detach because it left the template. preview-stack.sh names every delete and detach as a warning:

az stack-whatif "$SCOPE" create --name "$STACK_NAME" --stack-id "$STACK_ID" --location "$LOCATION" \
  "$SCOPE_FLAG" "$TARGET_ID" --parameters "$ROOT/$PARAMS" \
  --action-on-unmanage "$UNMANAGE" --deny-settings-mode "$DENY_MODE" \
  --retention-interval P1D --validation-level ProviderNoRbac \
  --no-pretty-print --output json

Both previews run as a second identity with Reader rights, in a GitHub environment <env>-preview that needs no approval, with --validation-level ProviderNoRbac. actionOnUnmanage and the deny settings come from platform.json in the preview and in the deployment, so the two can't disagree. For the management groups, both are off ("whatIf": false), because what-if can't preview groups the same deployment creates; the snapshot diff is the preview there.

The snapshot diff, what-if and stack what-if each catch a different kind of change
The snapshot diff, what-if and stack what-if each catch a different kind of change

9. Deploy, record, retire and rebuild in order

After the preview, the deploy job waits for a required reviewer on the GitHub environment, dev or prod. Then deploy-stack.sh creates or updates the stack at the scope platform.json names, and the run records the deployment for the next comparison:

az stack "$SCOPE" create --name "$STACK_NAME" --location "$LOCATION" "$SCOPE_FLAG" "$TARGET_ID" \
  --parameters "$ROOT/$PARAMS" --action-on-unmanage "$UNMANAGE" --deny-settings-mode "$DENY_MODE" \
  --description "solutions/$SOLUTION v$version, $ENVIRONMENT, commit $GITHUB_SHA" --yes

Stacks are named <solution>-<environment>, because the dev and prod management group stacks both live at the tenant root group. retire deletes the stack and, as actionOnUnmanage says, what it manages: everything for the sample app, nothing for the management groups.

rebuild.yml is the chain for a whole environment. It lists the environment's configurations from the folders, deploys the 01-core ones one after the other in folder order, then all 02-workloads in parallel, each through deploy.yml with its own preview and approval:

core:
  needs: plan
  if: ${{ needs.plan.outputs.core != '[]' }}
  strategy:
    max-parallel: 1 # in folder order: the platform deploys into groups the first one creates
    fail-fast: true
    matrix:
      config: ${{ fromJSON(needs.plan.outputs.core) }}
  uses: ./.github/workflows/deploy.yml
  with:
    config: ${{ matrix.config }}

A new environment, an environment rebuilt after a disaster and an update of everything are the same run. The init tier stays outside it. The bootstrap script registers the resource providers, creates what Entra Domain Services needs before it can deploy (the Domain Controller Services service principal and the AAD DC Administrators group), and creates the two app registrations GitHub Actions signs in as, with federated credentials instead of secrets. It runs once, as a Global Administrator with elevated access, and --dry-run prints every change first.

One environment, three tiers, deployed in order: init by hand, then core, then the workloads
One environment, three tiers, deployed in order: init by hand, then core, then the workloads

10. Put the conventions where agents load them

CI checks everything it can compute. The rest is judgement, such as whether a change is minor or major, whether this stack should own that resource, or whether a description helps someone choose a value. That goes into files the agents load:

  • AGENTS.md: the layout, five rules and the command to run before a push. CLAUDE.md is one line, @AGENTS.md, so Claude Code reads the same file as other agents.
  • Skills in .claude/skills/: bicep-conventions (tiers, ownership, naming, descriptions), bump-version (the table and the steps), review-component (run the checks, then judge what they can't).
  • Path rules that load with a Bicep file: .claude/rules/bicep.md for Claude Code, .github/instructions/bicep.instructions.md for GitHub Copilot. Both point at the skill and carry a canary comment, so you can ask an agent which rule it loaded.
  • .claude/settings.json: the checks are allowed, previews ask first, and deployments, publishing, role assignments and edits to snapshot files are denied.
"deny": [
  "Bash(az stack sub create:*)",
  "Bash(az stack mg create:*)",
  "Bash(az deployment mg create:*)",
  "Bash(scripts/deploy-stack.sh:*)",
  "Bash(scripts/publish-components.sh:*)",
  "Edit(**/*.snapshot.json)",
  "Write(**/*.snapshot.json)"
]

The fifth rule in AGENTS.md is for an agent that gets stuck: a red check is information, and an agent that can't make a check pass may not loosen the rule, add a suppression or skip the check.

CI computes, agents judge, people approve
CI computes, agents judge, people approve

Clone the starter, run the five checks in its README, then break one rule and watch the right check fail.

Pitfalls

  • Two Bicep CLIs. az bicep installs its own copy next to the one on your PATH, and bicep snapshot only exists in the standalone CLI. The starter's setup action installs the pinned version from platform.json, checks its SHA-256, and sets AZURE_BICEP_USE_BINARY_FROM_PATH=true, so az uses the same binary.
  • Tags pushed with GITHUB_TOKEN start no workflows. A publish workflow triggered by tags never runs when another workflow pushed them. publish.yml tags and publishes in one run.
  • A required check behind a path filter. When the filter skips it, the merge stays blocked. pr.yml has no path filter and one gate job.
  • Preview and deploy with different settings. The first version of the starter took actionOnUnmanage as a workflow input, so the preview could show detachAll and the deployment run deleteAll. Both now read platform.json, and a test checks they get the same flags.
  • Two stacks with the same name. Dev and prod management group stacks both sit at the tenant root group. A stack named after the solution alone would be overwritten by the other environment.
  • What-if on management groups that don't exist yet. It can't preview groups the same deployment creates. Turn it off for that solution and read the snapshot diff.
  • One managed domain per tenant. Entra Domain Services allows one per Microsoft Entra tenant, so dev can't have its own.
  • Entra Domain Services and gateway transit. Microsoft doesn't support remote gateways on the managed domain's peering. The identity spoke peers with the hub with useRemoteGateways: false.
  • A virtual network that declares subnets. subnets: [] deletes every subnet; leaving the property out keeps them. The vnet module never declares it, and every subnet is its own resource.
  • environment() in a snapshot. bicep snapshot can't evaluate it, so the private DNS zone privatelink.blob.${environment().suffixes.storage} stays an expression in the snapshot. It still diffs.
  • A date in a generated file. A timestamp in a README makes --check fail on the next run. Generate only what the template says.

FAQ

Why not one template with stages and deployment flags?

A template with stages is one stack: one lifecycle and one actionOnUnmanage for groups that live as long as the tenant and a platform that changes monthly. Two stacks keep those apart, each with its own preview. Chained in rebuild.yml, they still come back in order.

Can I run this on Azure DevOps instead of GitHub Actions?

Yes. Every check is a Bash script that knows nothing about the CI system except the ::error file=…:: annotation format, and the workflows only call the scripts. Two things need a replacement: the deployment record lives in GitHub's Deployments API, and approvals in GitHub environments.

Can I use Azure Verified Modules with this?

Yes, by exact version, like any other registry reference. The conversion script only rewrites your own br/platform alias; public references restore from the Microsoft registry as usual.

Why Entra Domain Services instead of domain controller VMs?

There are no domain controllers to patch, back up or size. The managed domain has limits: one per tenant, its own subnet, and no remote gateways on its peering. If you need forest trusts to on-premises or schema changes, check the Entra Domain Services documentation before you choose it.

Has the starter run against Azure?

Not yet. Its offline checks run on every pull request, and publish.yml has run on GitHub with Azure switched off. The README lists what to verify on the first run in a real tenant, such as a management group stack deploying into subscriptions two levels below it.

Sources

On the blog

History

  • 21 Oct 2026 First version. Replaces "Bicep IaC: one version per module, checked on every pull request".

Run Bicep like packages: pinned versions, one stack per lifecycle

Every Bicep change reaches Azure as a pinned version, previewed and approved, as one deployment stack per lifecycle. A public starter repo runs every check offline in minutes: CI that builds unpublished modules, generated READMEs and snapshots, then what-if on a three-tier landing zone.

A GitHub repo where a coding agent opens pull requests and can't merge without you, plus a one-page sign-off sheet for whoever approves it.

Get the starter kit