---
title: "Azure Bicep: Bulletproof Production Deployment Patterns"
description: "Own your Bicep blast radius: pin modules in a private registry, gate pull requests with lint and PSRule, and track production changes with stacks."
canonical: "https://adamtheautomator.com/azure-bicep-production-patterns/"
---

# Azure Bicep: Bulletproof Production Deployment Patterns

> Own your Bicep blast radius: pin modules in a private registry, gate pull requests with lint and PSRule, and track production changes with stacks.

Source: https://adamtheautomator.com/azure-bicep-production-patterns/

---

ATA Learning

Tap to hide

[

ATA Learning

](/)

*   [Home](/)
*   [Tutorials](/tutorials/)
*   [Instructors](/author/)
*   [Advertising](/advertising/)
*   [Recommended Resources](/resources/)
*   [About Adam](/about-adam/)

Search for:  

*   [](https://twitter.com/adbertram)
*   [](https://github.com/Adam-the-Automator)
*   [](https://www.linkedin.com/company/adam-the-automator-llc)
*   [](/feed/)

![Azure Bicep: Bulletproof Production Deployment Patterns](https://adamtheautomator.com/wp-content/uploads/publisher/2e05d9c85b2b8187b503fb0f6ceff47e/e15d54857176da3fe72b1f0692faaa7000367a5fbe6edf5f6a5f1034395fea45.webp)

# Azure Bicep: Bulletproof Production Deployment Patterns

[![](https://secure.gravatar.com/avatar/d0b9d42e21e5622713f8b693aa5c0f9244d5f7dd200ed29b8398f52dee5de337?s=192&d=mm&r=g)Adam Bertram](https://adamtheautomator.com/author/adam-bertram/)2 October 202620 min. read

Categories: [DevOps](/category/devops/)

Tags:[Azure](/tag/azure/)[Infrastructure as Code](/tag/infrastructure-as-code/)[GitHub Actions](/tag/github-actions/)[DevOps](/tag/devops/)

Table of Contents

*   [Advanced Bicep Patterns: The Controls a Template Cannot Enforce for Itself](#advanced-bicep-patterns-the-controls-a-template-cannot-enforce-for-itself)
*   [Pin the Toolchain, Then Pin the Modules](#pin-the-toolchain-then-pin-the-modules)
*   [Bicep Module Design: Modules Behave Like Published Interfaces](#bicep-module-design-modules-behave-like-published-interfaces)
*   [Draw the Boundary Where the Lifecycle Changes](#draw-the-boundary-where-the-lifecycle-changes)
*   [Consume AVM Unless You Have a Reason Not To](#consume-avm-unless-you-have-a-reason-not-to)
*   [Publish Once, Reference by Tag](#publish-once-reference-by-tag)
*   [Publishing to a Private Registry](#publishing-to-a-private-registry)
*   [Reference the Tag and Let the Linter Guard It](#reference-the-tag-and-let-the-linter-guard-it)
*   [Bicep Parameter Files: The Environment Contract](#bicep-parameter-files-the-environment-contract)
*   [Layer a Shared Baseline with extends](#layer-a-shared-baseline-with-extends)
*   [Keep Secrets Out of the Parameter File](#keep-secrets-out-of-the-parameter-file)
*   [Bicep CI/CD Pipeline Gates: Validate the Pull Request Before Azure Sees It](#bicep-cicd-pipeline-gates-validate-the-pull-request-before-azure-sees-it)
*   [The Offline Gates](#the-offline-gates)
*   [Log In with OIDC and No Stored Secret](#log-in-with-oidc-and-no-stored-secret)
*   [Put the Module Contract Under Test](#put-the-module-contract-under-test)
*   [Expand the Module So the Rules Can See It](#expand-the-module-so-the-rules-can-see-it)
*   [Assert the Contract with Pester](#assert-the-contract-with-pester)
*   [The Bicep Test Framework Is Experimental](#the-bicep-test-framework-is-experimental)
*   [Azure Deployment Stacks: A Managed Boundary](#azure-deployment-stacks-a-managed-boundary)
*   [The Two Parameters That Decide Your Blast Radius](#the-two-parameters-that-decide-your-blast-radius)
*   [A Stack Is Also a Workflow Object](#a-stack-is-also-a-workflow-object)
*   [When the Stack Update Fails Mid-Flight](#when-the-stack-update-fails-mid-flight)
*   [Read the Inventory Before You Touch Anything](#read-the-inventory-before-you-touch-anything)
*   [Roll Back by Re-Deploying the Last Good Commit](#roll-back-by-re-deploying-the-last-good-commit)
*   [The Production Readiness Checklist](#the-production-readiness-checklist)
*   [Ten Checks Before a Production Stack Goes Live](#ten-checks-before-a-production-stack-goes-live)

Do not hand a production resource group to an Azure Bicep deployment until you can answer one question about it: what happens to the resources your template does not mention. Azure will answer for you, and in Complete mode it answers by deleting them. Nothing in your Bicep file records them, your lint step has nothing to check, and the first real signal arrives as a support ticket about data that stopped existing.

Advanced Azure Bicep patterns come down to four decisions a template cannot make on its own: what a module owns, which version of that module each environment gets, what evidence has to exist before a change is allowed near production, and what Azure does with whatever the change leaves behind. Conditions, loops, and multi-scope targeting (`targetScope`) are out of scope here; this post follows one change, in one resource group, in the order it travels through the whole chain. It starts at a module interface and ends at an [Azure deployment stack](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/deployment-stacks) that tracks what the deployment owns. The last two sections deal with the part most Bicep writing skips, which is what a stack update does when it fails halfway and how you get back to a known-good state.

## Advanced Bicep Patterns: The Controls a Template Cannot Enforce for Itself

A Bicep file describes an end state. It does not describe the boundary of what it manages, and it cannot tell you what already exists in the resource group you are about to deploy into. Four controls cover that gap, and each one runs at a different point in the life of a change.

| Control | What it catches | Where it runs |
| --- | --- | --- |
| Compile and lint | Type errors, unused parameters and variables, secrets flowing into outputs | `az bicep build`, `az bicep lint` |
| Static policy analysis | Resources that break the [Well-Architected Framework](https://learn.microsoft.com/en-us/azure/well-architected/) or [your naming and tagging rules](https://adamtheautomator.com/build-azure-landing-zones/) | `Assert-PSRule`, the `microsoft/ps-rule` action |
| Pre-flight preview | Deletions, property changes, and drift between the template and live Azure | `az deployment group what-if` |
| Stack tracking | Resources that left the template and are now unmanaged but still billing | `az stack group create`, deny settings |

The order matters. Lint costs nothing and fails in seconds, so it runs first. Policy analysis reads the compiled template against a rule baseline and never talks to Azure. The what-if operation is the first control that needs live state, and it is also the first one that can be wrong: it reports properties as deleted when Azure sets them automatically at deploy time, and it cannot see resources that arrive through a `templateLink`. Stack tracking is the only control that still exists after the deployment returns.

![Wide single-row timeline diagram of the four control gates](https://adamtheautomator.com/wp-content/uploads/publisher/a018aa47566ab638ae743ab913f02ae0ae5c6ab08af93233f9ff90f537ffa22e.png)

### Pin the Toolchain, Then Pin the Modules

Two pipelines running [different Bicep CLI versions](https://adamtheautomator.com/azure-bicep/) can compile the same file into different ARM JSON, so pin both ends of that relationship: the compiler that reads the template and the module tags the template references. These are the versions this post is written against.

| Component | Version | Where the version is published |
| --- | --- | --- |
| Bicep CLI | v0.47.16 | [Bicep releases](https://github.com/Azure/bicep/releases) |
| Azure CLI | 2.90.0 | [Azure CLI release notes](https://learn.microsoft.com/en-us/cli/azure/release-notes-azure-cli?view=azure-cli-latest) |
| PSRule.Rules.Azure | v1.47.0 | [PSRule for Azure releases](https://github.com/Azure/PSRule.Rules.Azure) |
| Pester | 6.2.0 | [Pester releases](https://github.com/pester/Pester) |
| avm/res/storage/storage-account | 0.33.1 | [Public Bicep Registry tags](https://mcr.microsoft.com/v2/bicep/avm/res/storage/storage-account/tags/list) |

Installing a specific compiler version is one line in a pipeline step.

```bash
az bicep install --version v0.47.16
az bicep version
```

The second command is the check that matters, because a self-hosted agent carrying an older CLI silently compiles a template that a hosted runner builds differently. The [Bicep CLI reference](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/bicep-cli) lists the whole command surface, and `az bicep list-versions` shows what a given machine can install.

Pinning the toolchain settles which compiler reads your code. That says nothing about what the code owns, and the boundary is drawn by the module.

## Bicep Module Design: Modules Behave Like Published Interfaces

`module` in Bicep is a file reference, which makes it tempting to treat modules as folders full of resources. Production modules behave like published APIs instead: they declare a typed input contract, they declare what they expose back to callers, and they version independently of the file that consumes them. Microsoft’s [module documentation](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/modules) covers the mechanics of `module` and the `br:` reference scheme.

### Draw the Boundary Where the Lifecycle Changes

A module should own one primary resource, or a set of resources that are created and destroyed together. A virtual network and its subnets belong in one module because the subnets cannot outlive the network. A storage account and the role assignments owned by another team do not, because that team’s access model changes on its own schedule.

The contract below is a module interface. Every constraint the caller has to satisfy lives in a decorator, so a bad value fails at compile time and never reaches Azure.

```text
@description('Storage account name. Supplied by the caller, never hardcoded in the module.')
@minLength(3)
@maxLength(24)
param name string

@description('Deployment environment. Drives the tag contract and the default SKU.')
@allowed(['dev', 'test', 'prod'])
param environment string

@description('Storage SKU. Defaults to zone-redundant storage in production.')
@allowed(['Standard_LRS', 'Standard_ZRS', 'Standard_GRS'])
param skuName string = environment == 'prod' ? 'Standard_ZRS' : 'Standard_LRS'

@description('Tags applied to every resource this module creates.')
param tags object = {}

@description('Region for the storage account.')
param location string = resourceGroup().location

resource storage 'Microsoft.Storage/storageAccounts@2025-06-01' = {
  name: name
  location: location
  sku: {
    name: skuName
  }
  kind: 'StorageV2'
  properties: {
    minimumTlsVersion: 'TLS1_2'
    allowBlobPublicAccess: false
  }
  tags: tags
}

@description('Resource ID for modules that need a scope reference to this account.')
output resourceId string = storage.id

@description('Blob endpoint for app settings and diagnostic configurations.')
output blobEndpoint string = storage.properties.primaryEndpoints.blob
```

The two `output` blocks are what make this module composable. A parent template passes `storageAccount.outputs.blobEndpoint` into an App Service module without knowing the storage account’s name or resource group, and Bicep infers the deployment order from the reference instead of a hand-written `dependsOn`.

Two version-gated details matter once secrets cross a module boundary. A `@secure()` decorator on a module output, which returns a generated key to the caller without printing it in the deployment history, requires Bicep 0.35.1 or later. Assigning a user-assigned managed identity to a module requires 0.36.1. Both are covered in the same [module reference](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/modules#secure-parameters-and-outputs) that documents outputs.

Decorators on scalar parameters go a long way, and a [user-defined data type](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/user-defined-data-types) takes the same idea further by replacing a generic `object` with a declared shape that every caller is checked against. The `use-user-defined-types` rule ships off by default, so raising it in the [linter configuration file](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/bicep-config-linter) is what makes the compiler enforce the shape, and declaring a `type` opts that file into language version 2.0 code generation. One limit is worth knowing before you rely on it: `resourceInput<>` and `resourceOutput<>` derive a type from the resource schema, and Microsoft documents that Bicep checks those at compile time while the ARM service does not, which keeps them an authoring-time guardrail rather than a deployment-time control.

### Consume AVM Unless You Have a Reason Not To

[Azure Verified Modules](https://azure.github.io/Azure-Verified-Modules/) (AVM) is Microsoft’s supported module library, published under the MIT license from the [Azure/bicep-registry-modules](https://github.com/Azure/bicep-registry-modules) repository. Every module ships with defaults aligned to the Well-Architected Framework, so the unwritten configuration choices that cause most review arguments are already made.

| Your situation | What to build |
| --- | --- |
| A standard resource with defaults you would accept anyway | Reference the published `avm/res/...` module directly |
| A standard resource plus your naming, tagging, and policy requirements | A thin wrapper module that calls the AVM module and adds only your rules |
| A workload pattern spanning several resources and their roles | An `avm/ptn/...` pattern module, or a private one of your own |

Consuming one is a single declaration with a pinned tag.

```text
module storageAccount 'br/public:avm/res/storage/storage-account:0.33.1' = {
  name: 'storage-account'
  params: {
    name: storageName
    location: location
    kind: 'StorageV2'
    skuName: 'Standard_ZRS'
    tags: tags
  }
}
```

Microsoft retired the older non-AVM modules from the public registry, so references of the form `br/public:storage/storage-account:<tag>` and third-party community modules are no longer the supported path. AVM is the set Microsoft publishes, and the [module index](https://azure.github.io/Azure-Verified-Modules/indexes/bicep/) lists what exists and what state each module is in. Because the reference is pinned by tag, a module can publish new versions without changing what your environment deploys.

The tag is what makes an environment reproducible. Nothing enforces it unless you do, and that starts with where the module comes from.

## Publish Once, Reference by Tag

Copying a module folder into three environment repositories guarantees three versions of the same file within a quarter, and the drift is invisible until a production deployment behaves differently from the one that passed review. A private registry removes that failure mode. You publish the module once to an [Azure Container Registry](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-intro), and every environment references the same immutable tag through the `br:` scheme.

### Publishing to a Private Registry

The registry is an Azure Container Registry used as a module store. The [private module registry guide](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/private-module-registry) documents the minimum tooling: Bicep CLI 0.4.1008 or later and Azure CLI 2.31.0 or later. Creating the registry is one command.

```bash
az acr create \
  --name <registry-name> \
  --resource-group <registry-rg> \
  --sku Standard
```

Publishing a version is the second.

```bash
az bicep publish \
  --file modules/storage/main.bicep \
  --target br:<registry-name>.azurecr.io/bicep/modules/storage:v1.2.0 \
  --documentation-uri https://<docs-host>/storage-module
```

The target path is the module’s identity. `storage` is the module path and `v1.2.0` is the tag, so a consumer that needs different bytes asks for a different tag. Nothing about that request touches a branch.

* * *

_**Warning: _**`az acr create`**_ provisions a billable registry, and _**`az bicep publish`**_ fails unless your identity holds AcrPush on it. Create the registry in a platform subscription rather than an application resource group, and give consuming pipeline identities AcrPull only.**_

* * *

### Reference the Tag and Let the Linter Guard It

Consumers reference the published module by registry path. A registry alias in `bicepconfig.json` keeps those references short enough to read in a diff, because the alias expands to the registry and an optional module path prefix.

```text
module storage 'br/ContosoModules:storage:v1.2.0' = {
  name: 'storage-deploy'
  params: {
    name: storageName
    environment: environment
    skuName: skuName
    tags: tags
  }
}
```

Whether that reference stays honest depends on two linter behaviors. `az bicep lint` restores external modules automatically before checking them, and `--no-restore` suppresses that restore when the cache is already warm or the agent has no route to the registry. The `use-recent-module-versions` rule is off by default, so the linter will not complain about a year-old tag until you turn the rule on in the [Bicep linter configuration](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/bicep-config-linter).

Raising that rule to a warning is the cheapest governance you will get. The registry now fixes which bytes run in each environment, and the parameter file decides which values those bytes receive.

## Bicep Parameter Files: The Environment Contract

A Bicep parameter file uses the `.bicepparam` extension and is written in Bicep, so it gets type checking, IntelliSense, and expressions. It carries three things a JSON parameters file cannot: a `using` statement that binds it to a template, an `extends` statement that layers it on a shared baseline, and compile-time validation of the values you pass. Microsoft’s [parameter file reference](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/parameter-files) documents the full format.

### Layer a Shared Baseline with `extends`

Most values in an environment file are identical across environments, and duplicating them is how a test value reaches production. Put the shared set in a base file and override only what differs. The base has to assign every parameter the bound template marks required, because Bicep validates the extended file on its own: a required parameter left for an environment file to supply fails the whole chain with `BCP258` before either value is used. The base below gives `name` and `environment` defaults, and the environment files override them.

```text
// main.bicepparam
using './main.bicep'

param name = 'appprodst001'
param environment = 'dev'
param location = 'eastus2'
param tags = {
  owner: 'platform-team'
  costCenter: 'cc-4410'
  environment: 'base'
}
```

The production file inherits that baseline and overrides the values that change.

```text
// main.prod.bicepparam
using './main.bicep'
extends './main.bicepparam'

param environment = 'prod'
param skuName = 'Standard_ZRS'
```

Name the files `main.dev.bicepparam` and `main.prod.bicepparam` rather than duplicating templates per environment. The `using none` form, which decouples a parameter file from any single template so it can be reused across deployments, requires Bicep CLI 0.31.0 or later.

### Keep Secrets Out of the Parameter File

A parameter file is a text file in source control, and Microsoft’s guidance on the format is unambiguous: “A parameters file saves parameter values as plain text.” A database password committed there is a database password in git history. Keep the value in [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview) and resolve it at deployment time instead.

```text
param adminPassword = az.getSecret('<subscription-id>', '<key-vault-rg>', '<key-vault-name>', 'adminPassword')
```

Deploying with a `.bicepparam` requires Azure CLI 2.53.0 or later and Bicep CLI 0.22.X or later. Because `using` already names the template, the `--template-file` switch is unnecessary.

```bash
az deployment group create \
  --name app-prod \
  --resource-group <app-rg> \
  --parameters main.prod.bicepparam
```

Inline values still work alongside the file, and an inline value wins over the same parameter in the file. That precedence is useful for a one-off deployment name and dangerous for anything that decides a SKU.

The contract says what the deployment should contain. Nothing yet says who is allowed to run it.

## Bicep CI/CD Pipeline Gates: Validate the Pull Request Before Azure Sees It

The Bicep linter “checks Bicep files for syntax errors and best practice violations,” according to Microsoft’s [linter documentation](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/linter), and it ships in both the CLI and the VS Code extension. That is gate one. Gate two is a rule baseline read by PSRule for Azure. Both run without Azure credentials, which is exactly why they belong on the pull request rather than in the deployment job.

### The Offline Gates

This workflow compiles the template, then reads the compiled result against the Azure Well-Architected Framework (WAF) rules.

```yaml
name: infra-pr
on:
  pull_request:
    paths:
      - 'infra/**'

permissions:
  id-token: write
  contents: read

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Compile and lint
        run: az bicep build --file infra/main.bicep --stdout > /dev/null

      - name: Check against the WAF rule baseline
        uses: microsoft/ps-rule@v2.9.0
        with:
          modules: PSRule.Rules.Azure
          outcome: Fail
          outputFormat: Sarif
          outputPath: reports/ps-rule-results.sarif
```

`az bicep build --stdout` compiles the file and discards the JSON, so a syntax error exits non-zero and fails the job before anyone reviews the change. The [`microsoft/ps-rule` action](https://github.com/microsoft/ps-rule) installs the rules module from the PowerShell Gallery and asserts it; `outcome: Fail` limits the report to failing rules, and `outputFormat: Sarif` writes a SARIF file that a subsequent `github/codeql-action/upload-sarif` step can publish to the repository’s code-scanning tab. The PSRule for Azure [feature documentation](https://azure.github.io/PSRule.Rules.Azure/features/) describes the scope of what that baseline checks: “PSRule for Azure includes over 500 rules for validating resources against configuration recommendations.”

`permissions: id-token: write` is not decoration. Without it the workflow cannot mint the OIDC token the next step needs.

### Log In with OIDC and No Stored Secret

Passwordless authentication from [GitHub Actions](https://docs.github.com/en/actions) needs three identifiers and [a federated identity credential](https://adamtheautomator.com/entra-workload-identity-aks-no-more-secrets/) on a [Microsoft Entra](https://learn.microsoft.com/en-us/entra/fundamentals/what-is-entra) application or a user-assigned managed identity.

```yaml
      - name: Azure login
        uses: azure/login@v3
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
```

None of those three values is a secret, so none of them needs rotation. The credential that matters lives on the Azure side and trusts tokens GitHub issues for one specific repository, which is covered in Microsoft’s [OpenID Connect connection guide](https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure-openid-connect).

Two mistakes in that credential cost people an afternoon. Federated credential subjects match case-sensitively, so a subject of `repo:My-Org/My-Repo` fails against a token claiming `repo:my-org/my-repo` with `AADSTS700213` and a message about no matching federated identity record. The second trap is creating the credential with the optional GitHub owner and repository numeric IDs, which makes the portal generate a subject containing those IDs while the real token omits them. Both are documented in the [Azure Login action README](https://github.com/marketplace/actions/azure-login), and both look like a permissions problem when they are string-matching problems.

Pin the action version deliberately, because the documentation sits one version behind the marketplace. Microsoft’s [Bicep deployment guide for GitHub Actions](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/deploy-github-actions) is written around `azure/login@v2`, while the action’s own README shows `azure/login@v3`. Write down which one you pinned.

Gates catch what a template says, but proving that a module behaves takes running the module’s own assertions.

## Put the Module Contract Under Test

A module’s parameters arrive from its caller, so the tests that catch real defects are the ones that supply those parameters and inspect the resources that come out. That ground is covered by two tools today, and Microsoft’s own test framework is not yet the third.

### Expand the Module So the Rules Can See It

PSRule for Azure expands Bicep deployments to evaluate the resources inside them, but a module file with required parameters cannot be expanded on its own because nothing supplies those parameters. The [documented pattern](https://azure.github.io/PSRule.Rules.Azure/using-bicep/) is to exclude module files from discovery and include a small test deployment per module.

```yaml
# ps-rule.yaml
configuration:
  AZURE_BICEP_FILE_EXPANSION: true
input:
  pathIgnore:
    - 'bicepconfig.json'
    - 'infra/modules/**/*.bicep'
    - '!infra/modules/**/*.tests.bicep'
```

```text
// infra/modules/storage/.tests/main.tests.bicep
module test_required_params '../main.bicep' = {
  name: 'test_required_params'
  params: {
    name: 'sttest001'
    environment: 'test'
  }
}
```

The negated pattern on the last line is what keeps the module’s test deployment in play. The `infra/modules/**/*.bicep` line above it also matches `infra/modules/storage/.tests/main.tests.bicep`, so removing the negation drops that file from analysis rather than triggering an expansion error. Drop the module line as well and PSRule expands each module file directly. That expansion fails, because nothing supplies the required `environment` parameter, and the run reports an error that reads like a rule violation.

### Assert the Contract with Pester

Pester covers the checks a rule baseline cannot express: whether a module still exposes the outputs its callers depend on. The pipeline pins Pester 6.2.0, and these assertions use the `Should-*` command form that shipped in Pester 6.

```powershell
BeforeAll {
    $template = az bicep build --file ./infra/modules/storage/main.bicep --stdout | ConvertFrom-Json
    $resource = $template.resources.storage
}

Describe 'storage module contract' {
    It 'refuses public blob access by default' {
        $resource.properties.allowBlobPublicAccess | Should-BeFalse
    }

    It 'enforces TLS 1.2 as the minimum version' {
        $resource.properties.minimumTlsVersion | Should-BeString 'TLS1_2'
    }

    It 'publishes the blob endpoint its callers need' {
        $template.outputs.blobEndpoint.type | Should-BeString 'string'
    }
}
```

`$template.resources.storage` indexes the account by the symbolic name it carries in the compiled template. With `assertions` enabled the compiler emits language version 2.1-experimental and serializes `resources` as an object keyed by symbolic name instead of an array, so `$template.resources[0]` returns a wrapper instead of the storage account and `$resource.properties` is null. The assertions read [the compiled ARM JSON](https://adamtheautomator.com/azure-bicep-vs-arm-templates/) rather than your source, which is the point. Deleting `allowBlobPublicAccess` from the module makes the first test fail, and renaming the output makes the third fail, both before a deployment exists to break. Classic `Should -Be` assertions still work in Pester 6, so a suite written against Pester 5 keeps running while you migrate file by file. What changed is that `Assert-MockCalled` and `Assert-VerifiableMock` are gone, with mock verification moving to `Should -Invoke` (and `Assert-VerifiableMock` to `Should -InvokeVerifiable`).

### The Bicep Test Framework Is Experimental

Bicep ships an `assert` keyword for boolean assertions about a parameter, variable, or resource name, plus a `test` block for offline unit tests in a separate `test.bicep` file, both enabled through `bicepconfig.json`.

```json
{
  "experimentalFeaturesEnabled": {
    "testFramework": true,
    "assertions": true
  }
}
```

Tests run with `bicep test <filepath>`, and because assertions evaluate client-side they validate earlier in the lifecycle than anything that touches Azure. Read the warning in the [experimental features documentation](https://github.com/Azure/bicep/blob/main/docs/experimental-features.md) before you wire this into a production gate: “Experimental features should be enabled for testing purposes only, as there are no guarantees about the quality or stability of these features.” Treat `bicep test` as an early layer on a branch, and keep PSRule plus what-if as the gates that actually hold.

## Azure Deployment Stacks: A Managed Boundary

Everything so far controls a change while it is still a file. A deployment stack controls the change after it lands, by keeping an inventory of every resource the deployment created. Microsoft describes the mechanism directly in the [deployment stack documentation](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/deployment-stacks): “An Azure deployment stack is a resource that enables you to manage a group of Azure resources as a single, cohesive unit.”

That inventory answers the question from the first paragraph. Incremental mode leaves a removed resource running and unmanaged. Complete mode deletes everything in the target scope that the template omits, including resources other teams created. A stack does neither without your instruction, which means the instruction is now a production input that goes through review like any other.

### The Two Parameters That Decide Your Blast Radius

`actionOnUnmanage` and `denySettingsMode` decide what a stack does with resources it stops managing and what it locks down. Both accept three values, and both are frequently documented with only two.

| Parameter | Value | What happens |
| --- | --- | --- |
| `--action-on-unmanage` | `detachAll` | The resource leaves stack tracking and keeps running in Azure |
| `--action-on-unmanage` | `deleteResources` | Unmanaged resources are deleted; resource groups and management groups are detached |
| `--action-on-unmanage` | `deleteAll` | Unmanaged resources, resource groups, and management groups are all deleted |
| `--deny-settings-mode` | `none` | Managed resources carry no lock |
| `--deny-settings-mode` | `denyDelete` | Managed resources cannot be deleted outside the stack |
| `--deny-settings-mode` | `denyWriteAndDelete` | Managed resources cannot be modified or deleted outside the stack |

Deny settings are implemented as deny assignments, so they stop a principal that holds Owner at the scope. Two escape hatches keep that from becoming permanent: `--deny-settings-excluded-principals` accepts up to five principals, and `--deny-settings-excluded-actions` accepts up to 200 actions. The [`az stack group` reference](https://learn.microsoft.com/en-us/cli/azure/stack/group?view=azure-cli-latest) lists every parameter, and `--action-on-unmanage` and `--deny-settings-mode` are both required on create rather than defaulted.

![Three-step escalation ladder for deployment stack blast radius](https://adamtheautomator.com/wp-content/uploads/publisher/68f7f7eef43ae70d3e2daa396e9d89582fce1e886f3d8719a90866490b281b17.png)

Create the stack once, then update the same stack on every later deployment.

```bash
az stack group create \
  --name app-prod-stack \
  --resource-group <app-rg> \
  --template-file infra/main.bicep \
  --parameters infra/main.prod.bicepparam \
  --action-on-unmanage deleteResources \
  --deny-settings-mode denyDelete \
  --yes
```

* * *

_**Warning: _**`az stack group create`**_ deploys real resources, and with _**`deleteResources`**_ or _**`deleteAll`**_ it destroys real resources on a later update. Run the same stack name against a non-production resource group first, and read the stack’s what-if output before the first production run.**_

* * *

### A Stack Is Also a Workflow Object

The [Azure/bicep-deploy action](https://github.com/Azure/bicep-deploy) runs stacks from GitHub Actions with the same inputs mapped to action parameters, so the stack is versioned and reviewed like the template it deploys.

```yaml
      - name: Deploy stack
        uses: azure/bicep-deploy@v2
        with:
          type: deploymentStack
          operation: create
          name: app-prod-stack
          location: eastus2
          scope: resourceGroup
          resource-group-name: <app-rg>
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
          template-file: ./infra/main.bicep
          parameters-file: ./infra/main.prod.bicepparam
          action-on-unmanage-resources: delete
          deny-settings-mode: denyWriteAndDelete
```

Azure Pipelines gets the same capability through the first-party `BicepDeploy@0` task with `type: deploymentStack`, which also handles Bicep CLI download and caching, what-if previews, and output masking. That task requires agent software 2.144.0 or later, as documented in the [Azure Pipelines integration guide](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/add-template-to-azure-pipelines), and Microsoft-hosted agents always satisfy it while self-hosted agents need checking.

A stack gives you an inventory. An inventory is what you need on the day a deployment does not finish.

## When the Stack Update Fails Mid-Flight

Stack updates fail the way every other deployment fails: a rejected API version, a quota, a policy assignment blocking a resource, or a property the resource provider refuses. What separates a stack from an ordinary deployment is what the failure leaves behind. An update that fails after creating three of five resources leaves those three in the stack’s inventory, so the next run treats a partial environment as the baseline it should reconcile against. Azure reports the disagreement as an out-of-sync error, and `--bypass-stack-out-of-sync-error` exists to acknowledge and clear it once you understand the difference.

### Read the Inventory Before You Touch Anything

The stack resource holds the list you need, and reading it costs nothing.

```bash
az stack group show \
  --name app-prod-stack \
  --resource-group <app-rg> \
  --query "{state:provisioningState, mode:denySettings.mode, resources:resources[].id}"
```

`az stack group show` returns the stack object with its properties at the top level, so the query paths carry no `properties.` wrapper. Add the prefix back and every field resolves to null while the command exits 0, which is how an empty inventory gets mistaken for an empty stack. The full set of stack operations, including the bypass flag for an out-of-sync inventory, is documented in the [Azure CLI stack group commands](https://learn.microsoft.com/en-us/cli/azure/stack/group?view=azure-cli-latest). Compare that inventory against the resource group’s actual contents, because the gaps are your decision surface.

![Two-panel before-and-after diagram of a failed deployment stack update](https://adamtheautomator.com/wp-content/uploads/publisher/16ecd01ad75a50c4fbb7520df341975ee0659f6eb528a383b395f171f9925108.png)

| What you find | What it means | First move |
| --- | --- | --- |
| `provisioningState` of Failed with a partial list | Some resources deployed, the rest did not | Fix the template, then re-run the same stack create so the stack reconciles to it |
| Resources in the group the stack does not track | Orphans from a failed run, or resources created by hand | Add them to the template, or detach and delete them deliberately |
| Deny settings blocking a cleanup | Deny assignments from an earlier stack revision | Remove the deny settings, or add the cleanup identity to the excluded principals |
| An out-of-sync error on the next create | Stack inventory disagrees with the resource group | Reconcile both lists first, then bypass the error knowingly |

Detaching an orphan without destroying it is a stack delete with the least destructive action available.

```bash
az stack group delete \
  --name app-prod-stack \
  --resource-group <app-rg> \
  --action-on-unmanage detachAll \
  --yes
```

That command removes the stack resource and leaves every resource it managed running in Azure. Change `detachAll` to `deleteResources` and the same command destroys them, which is the difference between clearing a stale inventory and deleting a production database.

* * *

_**Pro Tip: put your deployment identity in _**`--deny-settings-excluded-principals`**_ from the first stack revision. The parameter accepts up to five principals, and a deny assignment does not care that the pipeline’s own service principal created the resource it now refuses to remove.**_

* * *

### Roll Back by Re-Deploying the Last Good Commit

Bicep has no rollback command and a stack has no undo operation, so “roll back” means running the previous template again and letting the stack reconcile the resource group to it. Check out the last commit that deployed cleanly, validate it against the same stack, and deploy.

```bash
az stack group validate \
  --name app-prod-stack \
  --resource-group <app-rg> \
  --template-file infra/main.bicep \
  --parameters infra/main.prod.bicepparam \
  --action-on-unmanage deleteResources \
  --deny-settings-mode denyDelete
```

Two constraints decide whether that procedure is available when you need it. Rollback restores infrastructure only, so anything with a stateful dependency needs its own recovery path. Size is the other limit: if one stack owns all forty resources in an environment, “the resources this template owns” is a set nobody can reason about during an incident. Splitting a single `main.bicep` into a networking stack, a data stack, and an application stack keeps each rollback to one layer while the others hold still.

That split also makes the recovery table above actionable. A failed networking stack and a failed data stack have different owners and different rollback windows, so the rollback stays confined to the stack that broke.

## The Production Readiness Checklist

Work through these infrastructure as code best practices before a stack deploys to production for the first time, and attach the answers to the pull request that introduces it. Two of them are decisions rather than settings, and those are the ones that get skipped when a delivery date is close.

### Ten Checks Before a Production Stack Goes Live

1.  The compiler version is pinned in the pipeline and asserted with `az bicep version`, so a stale agent cannot compile a different template than the one under review.
    
2.  Every module reference carries an explicit tag, and the [recent-module-versions linter rule](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/linter-rule-use-recent-module-versions) is switched on so an aging tag shows up in review.
    
3.  Every environment file is a `.bicepparam` with a `using` statement, shared values live in one base file reached through `extends`, and no literal secret appears in any of them.
    
4.  `az bicep build` and the PSRule baseline both run on the pull request, and neither one needs Azure credentials.
    
5.  Modules with required parameters carry a `.tests` file so PSRule can expand them, and no module is excluded from analysis silently.
    
6.  The what-if output for the stack is attached to the change that alters a production resource group.
    
7.  Stack names follow lifecycle boundaries rather than team boundaries, so a rollback touches one layer.
    
8.  `actionOnUnmanage` and `denySettingsMode` are reviewed as production inputs, with the escalation ladder recorded in the repository rather than in someone’s memory.
    
9.  The deployment identity appears in `--deny-settings-excluded-principals`, with at least one other break-glass principal alongside it.
    
10.  The recovery path is written down: which commit is the last known good one, who can run the stack delete, and which resources need a state-level restore instead.
     

Every item on that list is a decision your team makes once and then records in the repository, which is the part that does not survive a delivery crunch. Start with the two that decide blast radius: what the stack owns, and what it does when a resource leaves the template.

Share this article

[Share on X](https://twitter.com/intent/tweet?url=https%3A%2F%2Fadamtheautomator.com%2Fazure-bicep-production-patterns%2F&text=Azure%20Bicep%3A%20Bulletproof%20Production%20Deployment%20Patterns)[Share on Facebook](https://www.facebook.com/sharer/sharer.php?u=https%3A%2F%2Fadamtheautomator.com%2Fazure-bicep-production-patterns%2F)[Share on LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fadamtheautomator.com%2Fazure-bicep-production-patterns%2F)

## Related Posts

![](https://adamtheautomator.com/wp-content/uploads/2026/09/featured_image-1.webp)

### [Bicep: Never Hand-Write Azure ARM JSON Again](/azure-bicep-vs-arm-templates/)

Learn how Bicep simplifies Azure infrastructure deployment with domain-specific language, dependency inference, and reusable modules for cleaner IaC.

![](https://adamtheautomator.com/wp-content/uploads/2026/08/featured_image-5.webp)

### [Azure DevOps Boards: Trace Every Commit to Deployment](/azure-devops-boards-traceability/)

Configure Azure DevOps Boards process templates, backlogs, and Kanban WIP limits, then trace every work item from commit to deployment.

![](https://adamtheautomator.com/wp-content/uploads/2026/03/featured_image.webp)

### [Prove Every Artifact: Supply Chain Security in Azure DevOps](/supply-chain-security-azure-devops/)

Learn to implement software supply chain security in Azure DevOps with SBOM generation, artifact signing, dependency scanning, and deployment gate enforcement.

## Categories

*   [IT Ops](/category/it-ops/)
*   [Cloud](/category/cloud/)
*   [DevOps](/category/devops/)
*   [Home Ops](/category/home-ops/)
*   [Information Security](/category/infosec/)
*   [Software Development](/category/software-development/)

## Site

*   [Home](/)
*   [Tutorials](/tutorials/)
*   [Instructors](/author/)
*   [Advertising](/advertising/)
*   [Recommended Resources](/resources/)
*   [About Adam](/about-adam/)

Copyright 2026© ATA Learning | [Privacy Policy](/privacy/)
