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:
--action-on-unmanagedecides what happens to resources that fall out of the stack’s management.deleteResourcesdeletes the resources but leaves their resource groups;deleteAllremoves resource groups too;detachResourcesorphans them (leaves them running, unmanaged). Start withdetachResourcesin production until you trust the diff, then move todeleteResources.--deny-settings-modeapplies a write/delete lock on the managed resources so humans cannot mutate them out-of-band in the portal.denyDeleteblocks deletion;denyWriteAndDeleteblocks both. Usenoneonly while iterating.
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-actionsto 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:
- You inherited ARM templates. Decompile them as a starting point:
az bicep decompile --file azuredeploy.json. Treat the output as a draft, not gold. The decompiler does a mechanical translation and frequently emits// @TODOmarkers, awkward names, and verbose expressions you should clean up by hand. - A tool only emits ARM JSON (some exported templates, marketplace artifacts). You can consume an ARM JSON file directly as a Bicep module:
module x 'foo.json' = { ... }. No conversion required. - You need to hand someone JSON.
az bicep buildis your export; the generated ARM is committed nowhere and produced on demand.
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
- Treating what-if as ground truth. It has false positives on certain providers and cannot evaluate
!Deploy effects. Review, do not blindly trust. deleteAllin production by reflex. A wrong template diff can delete a resource group. Start stacks withdetachResources, graduate todeleteResources, and reservedeleteAllfor genuinely disposable environments.- Floating module references. Always pin registry module versions; an unpinned move makes deployments irreproducible.
- Secrets in
.bicepparam. They land in source control and pipeline logs. UsegetSecretso the value resolves server-side. - Hand-editing decompiled JSON. Once you author in Bicep, the JSON is a build artifact. Editing it breaks the source-of-truth model and your next
bicep buildoverwrites it.