IaC Azure

Shipping Azure Workloads with Bicep: Deployment Stacks, what-if, and a CI Pipeline

Most application teams adopt Bicep, write one giant main.bicep, deploy it with az deployment group create, and then spend the next year afraid to run it again because nobody knows what it will change or what it will orphan. This is a practitioner’s guide to doing it properly: decompose the workload into modules, preview every change with what-if, manage lifecycle with deployment stacks, and gate the whole thing behind a CI pipeline that fails loud before anything reaches a subscription.

I assume you already have the Azure CLI installed and an az login session. Everything here targets the current Bicep toolchain (Bicep CLI bundled with az 2.50+) and the GA deployment stacks feature.

1. Bicep fundamentals that matter day to day

You do not need the whole language. Three concepts carry 90% of real work: parameters, modules, and scopes.

A minimal resource declaration with a typed, validated parameter looks like this:

@description('Globally unique storage account name')
@minLength(3)
@maxLength(24)
param storageName string

@allowed(['Standard_LRS', 'Standard_ZRS', 'Standard_GRS'])
param skuName string = 'Standard_LRS'

param location string = resourceGroup().location

resource sa 'Microsoft.Storage/storageAccounts@2023-05-01' = {
  name: storageName
  location: location
  sku: {
    name: skuName
  }
  kind: 'StorageV2'
  properties: {
    minimumTlsVersion: 'TLS1_2'
    allowBlobPublicAccess: false
    supportsHttpsTrafficOnly: true
  }
}

output storageId string = sa.id

The decorators (@minLength, @allowed, @description) are not cosmetic. They are enforced at compile time and surface in what-if, in the portal’s custom-deployment UI, and in lint output. Use them.

Scopes are the other thing to internalize. A .bicep file has a targetScope, and the default is resourceGroup. The four that matter:

targetScope What it deploys to Typical use
resourceGroup One resource group App workloads (the common case)
subscription A subscription Creating resource groups, policy, RBAC
managementGroup A management group Org-wide governance
tenant The tenant root Rare; landing-zone bootstrap

As an app team you live almost entirely in resourceGroup scope. You hit subscription scope only when a module needs to create the resource group itself. Mixing scopes is done by deploying a module at a different scope, which I cover next.

2. Decompose the workload into modules

A module is just another .bicep file consumed via the module keyword. Decompose by lifecycle and ownership, not by resource type. A pragmatic web-app layout:

infra/
  main.bicep            # orchestration only
  modules/
    storage.bicep
    appplan.bicep
    webapp.bicep
    keyvault.bicep
  env/
    dev.bicepparam
    prod.bicepparam

main.bicep wires modules together and passes outputs from one as inputs to the next. Bicep infers dependency order from these references, so you rarely write dependsOn by hand.

targetScope = 'resourceGroup'

@allowed(['dev', 'prod'])
param env string
param location string = resourceGroup().location

var namePrefix = 'kv${env}'

module plan 'modules/appplan.bicep' = {
  name: 'appplan'
  params: {
    name: '${namePrefix}-plan'
    location: location
    sku: env == 'prod' ? 'P1v3' : 'B1'
  }
}

module web 'modules/webapp.bicep' = {
  name: 'webapp'
  params: {
    name: '${namePrefix}-web'
    location: location
    serverFarmId: plan.outputs.planId
  }
}

The name property on a module is the nested deployment name in Azure, not the resource name. Keep it short and stable; long generated names are what produce the dreaded DeploymentName length exceeds limit error in large templates.

Publishing modules to a Bicep registry

When more than one repo consumes the same module, stop copying files and publish to a registry. The registry is just an Azure Container Registry; Bicep modules are stored as OCI artifacts.

# One-time: create the ACR (or reuse an existing one)
az acr create \
  --resource-group rg-platform-shared \
  --name kvplatformacr \
  --sku Basic

# Publish a module version
az bicep publish \
  --file modules/storage.bicep \
  --target br:kvplatformacr.azurecr.io/bicep/storage:1.2.0

Consumers reference it with a br: path and a pinned version:

module sa 'br:kvplatformacr.azurecr.io/bicep/storage:1.2.0' = {
  name: 'storage'
  params: {
    storageName: 'kvdevstg001'
    skuName: 'Standard_LRS'
  }
}

Pin exact versions. Bicep’s registry references are immutable tags, not floating ranges, which is exactly what you want for reproducible deployments. Add an alias in bicepconfig.json so consumers do not repeat the full registry URL:

{
  "moduleAliases": {
    "br": {
      "platform": {
        "registry": "kvplatformacr.azurecr.io",
        "modulePath": "bicep"
      }
    }
  }
}

That turns the reference into br/platform:storage:1.2.0.

3. Preview changes with what-if (and actually read it)

what-if is the single most important habit for safe Bicep. It calls the ARM what-if API to compute the difference between the deployed state and your template, then prints a color-coded diff.

az deployment group what-if \
  --resource-group rg-kvdev-web \
  --template-file infra/main.bicep \
  --parameters infra/env/dev.bicepparam

Read the change-type symbols carefully:

Symbol Type Meaning
+ Create New resource
- Delete Resource removed (stacks only act on this; see below)
~ Modify In-place update of properties
! Deploy Resource will be redeployed; effect unknown to the engine
(no change) NoChange / Ignore No diff, or a property the engine cannot evaluate

Two honest caveats. First, what-if has noise: some resource providers report spurious ~ modifications on read-only or default-populated properties. You learn your workload’s false positives. Second, a ! “Deploy” line means the engine could not predict the effect, not that nothing happens; treat those as “review by hand.”

For pipeline gating you want machine-readable output and a summary that hides the no-change clutter:

az deployment group what-if \
  --resource-group rg-kvdev-web \
  --template-file infra/main.bicep \
  --parameters infra/env/dev.bicepparam \
  --result-format ResourceIdOnly \
  --no-pretty-print > whatif.json

--result-format FullResourcePayloads (the default) gives property-level diffs; ResourceIdOnly gives just the resource list and change type, which is plenty for a PR comment.

4. Lifecycle management with deployment stacks

A plain deployment is fire-and-forget: it creates and updates, but it never cleans up resources you delete from the template. A deployment stack is a managed resource that owns a set of resources. Remove a resource from the template, redeploy the stack, and the stack deletes the orphan. This is the closest Bicep gets to terraform destroy and state-tracked drift.

Create or update a stack at resource-group scope:

az stack group create \
  --name stack-kvdev-web \
  --resource-group rg-kvdev-web \
  --template-file infra/main.bicep \
  --parameters infra/env/dev.bicepparam \
  --action-on-unmanage deleteResources \
  --deny-settings-mode denyDelete \
  --yes

Two flags do the heavy lifting:

Callout: deny-settings are enforced by Azure on the managed resources, independent of RBAC. Even an Owner gets blocked. That is the point. Use --deny-settings-excluded-actions to punch specific holes (for example, allowing a key rotation) without disabling the lock entirely.

Tear the whole thing down cleanly when the environment is retired:

az stack group delete \
  --name stack-kvdev-web \
  --resource-group rg-kvdev-web \
  --action-on-unmanage deleteResources \
  --yes

That deletes every resource the stack manages, in dependency order, with one command. No leftover NICs, no orphaned disks, no surprise bill next month.

5. Parameterize per environment with .bicepparam and Key Vault

Stop passing a wall of --parameters key=value on the command line. Use typed .bicepparam files, which are real Bicep and get compiled and type-checked against the template.

env/dev.bicepparam:

using '../main.bicep'

param env = 'dev'
param location = 'eastus'

env/prod.bicepparam:

using '../main.bicep'

param env = 'prod'
param location = 'eastus2'

The using statement binds the param file to its template, so your editor flags a missing or mistyped parameter immediately.

For secrets, never put values in the param file. Reference Key Vault and let ARM pull the secret at deploy time. This requires the getSecret function on an existing vault resource, used inside a module parameter:

resource kv 'Microsoft.KeyVault/vaults@2023-07-01' existing = {
  name: 'kv-shared-secrets'
  scope: resourceGroup('rg-platform-shared')
}

module web 'modules/webapp.bicep' = {
  name: 'webapp'
  params: {
    name: 'kvprod-web'
    location: location
    sqlConnectionString: kv.getSecret('sql-conn-string')
  }
}

The secret value never enters your template text, your logs, or what-if output; it is resolved server-side. The deploying identity needs get permission on that secret (RBAC role Key Vault Secrets User or an equivalent access policy), and the vault must have enabledForTemplateDeployment set if you use access policies rather than RBAC.

6. Validate and lint in CI

Three layers of validation, cheapest first.

Build / compile. bicep build catches syntax errors and unresolved references with zero Azure calls. Run it on every file:

az bicep build --file infra/main.bicep --stdout > /dev/null

Lint. The Bicep linter runs during build and is configured in bicepconfig.json. Promote the rules you care about to error so CI fails on them:

{
  "analyzers": {
    "core": {
      "enabled": true,
      "rules": {
        "no-hardcoded-env-urls": { "level": "error" },
        "secure-parameter-default": { "level": "error" },
        "no-unused-params": { "level": "warning" },
        "prefer-interpolation": { "level": "warning" }
      }
    }
  }
}

Policy / best-practice scanning. PSRule for Azure evaluates your Bicep against the Well-Architected Framework and Azure best practices (TLS versions, public access, diagnostic settings, and so on). It runs as a PowerShell module or a packaged GitHub Action / Azure DevOps task:

Install-Module -Name PSRule.Rules.Azure -Scope CurrentUser -Force
Assert-PSRule -InputPath './infra/' -Module 'PSRule.Rules.Azure' -Format File

PSRule expands Bicep to ARM internally, so it sees the resolved resource graph, catching issues the linter cannot.

7. A gated pipeline: what-if on PR, stack deploy on merge

Tie it together: on a pull request, build, lint, scan, and post a what-if so reviewers see the blast radius. On merge to main, require an approval, then deploy the stack. Here is a GitHub Actions skeleton using OIDC federated credentials (no stored secrets):

name: bicep-deploy
on:
  pull_request:
    paths: ['infra/**']
  push:
    branches: [main]
    paths: ['infra/**']

permissions:
  id-token: write   # required for OIDC login
  contents: read
  pull-requests: write

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - name: Build and lint
        run: az bicep build --file infra/main.bicep --stdout > /dev/null
      - name: PSRule scan
        uses: microsoft/ps-rule@v2
        with:
          modules: PSRule.Rules.Azure
          inputPath: infra/

  preview:
    needs: validate
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - name: what-if
        run: |
          az deployment group what-if \
            --resource-group rg-kvdev-web \
            --template-file infra/main.bicep \
            --parameters infra/env/dev.bicepparam

  deploy:
    needs: validate
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: prod   # attach required reviewers here
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - name: Deploy stack
        run: |
          az stack group create \
            --name stack-kvprod-web \
            --resource-group rg-kvprod-web \
            --template-file infra/main.bicep \
            --parameters infra/env/prod.bicepparam \
            --action-on-unmanage deleteResources \
            --deny-settings-mode denyWriteAndDelete \
            --yes

The approval gate is the GitHub environment named prod with required reviewers configured in repo settings; the job blocks on it before the deploy step runs. Azure DevOps achieves the same with an environment + approval check before the stack task.

8. Bicep vs. ARM JSON: decompiling and interop

Bicep compiles to ARM JSON; ARM JSON is the only thing Azure actually executes. That makes interop a non-issue most of the time:

Rule of thumb: author in Bicep, never hand-edit the generated JSON, and decompile only to migrate, not as an ongoing workflow.

Verify

Confirm each layer works before trusting the pipeline.

# 1. Template compiles with no errors or promoted-lint failures
az bicep build --file infra/main.bicep --stdout > /dev/null && echo "build ok"

# 2. what-if returns a sensible diff (and the CLI exit code is 0)
az deployment group what-if \
  --resource-group rg-kvdev-web \
  --template-file infra/main.bicep \
  --parameters infra/env/dev.bicepparam

# 3. The stack exists and reports a succeeded provisioning state
az stack group show \
  --name stack-kvdev-web \
  --resource-group rg-kvdev-web \
  --query "provisioningState" -o tsv

# 4. The stack is actually managing the resources you expect
az stack group show \
  --name stack-kvdev-web \
  --resource-group rg-kvdev-web \
  --query "resources[].id" -o tsv

# 5. Deny-settings are enforced: this delete should FAIL with a deny error
az resource delete --ids <managed-resource-id>   # expect: blocked

If step 5 succeeds, your deny-settings mode is none or the resource is not actually managed by the stack. That is the most common silent misconfiguration.

Checklist

Pitfalls

BicepAzureDeployment Stackswhat-ifCI

Comments

Keep Reading