ARM & Bicep

Azure-native IaC, modules, idempotency.

Intermediate30 min · lesson 7 of 15

A shell script that builds infrastructure is a recipe. Run step one, then step two, and hope nothing you are about to create already exists. A Bicep file is a thermostat. You write down the state you want (one storage account, TLS 1.2 enforced, public blob access off) and Azure works out whether to create something, change something, or do nothing at all. Run it once or fifty times and you land in the same place. That property is idempotency, and it is what makes infrastructure safe to deploy from a pipeline on every merge. TLS, by the way, is Transport Layer Security, the encryption that puts the padlock in your browser bar. On Azure the engine underneath all of this is an ARM template, and the language you actually want to type is Bicep.

ARM is the engine, Bicep is the language you write in

Azure Resource Manager (ARM) is Azure's control plane, the single front door every create, update and delete has to walk through. Click a button in the portal, run the CLI (command line interface), apply Terraform: it all arrives as the same API calls to ARM. An ARM template is a JSON (JavaScript Object Notation, a plain-text data format) document that lists the resources you want rather than the steps to build them. ARM's deployment engine compares that list against what already exists and issues only the changes needed. You can write raw ARM JSON. It is painful. Expressions hide inside quoted strings like "[resourceGroup().location]", and real templates nest four levels deep before you reach anything interesting. Bicep is a small language that compiles one-to-one into ARM JSON. Run az bicep build -f main.bicep and you can read the exact JSON that ARM will receive. Same engine, same features, about a tenth of the noise. One property matters more than it sounds: Bicep keeps no state file. ARM already knows what exists, so there is nothing to lock, corrupt or leak.

Two internals matter before you deploy anything. The first is deployment mode. The default, *incremental*, touches only the resources named in your template and leaves the rest of the resource group alone. *Complete* mode goes further and deletes anything in that resource group your template does not mention. Useful when you want the template to be the whole truth. Catastrophic the day someone forgets to include a resource. The second is that a deployment is itself a resource. ARM writes every one into the resource group's history, which you can list with az deployment group list, so you always have a record of who converged what, and when.

infra/storage.bicep
// Declarative, PR-reviewed, idempotent Azure IaC
@description('Globally unique, 3-24 lowercase alphanumerics')
@minLength(3)
@maxLength(24)
param name string
param location string = resourceGroup().location
resource sa 'Microsoft.Storage/storageAccounts@2023-05-01' = {
name: name
location: location
sku: { name: 'Standard_ZRS' }
kind: 'StorageV2'
properties: {
allowBlobPublicAccess: false // secure defaults live in code —
minimumTlsVersion: 'TLS1_2' // weakening one is a visible diff,
supportsHttpsTrafficOnly: true // not a silent portal click
}
}
output blobEndpoint string = sa.properties.primaryEndpoints.blob

Preview the diff, then let it converge

The working loop has three verbs. Preview, create, verify. az deployment group what-if renders the difference between your template and the live resources, which is Bicep's answer to terraform plan. It marks each resource with + for create, ~ for modify and - for delete. Read it before every production deploy. That is the last moment a wrong parameter still costs you nothing. Then az deployment group create submits the template and blocks until provisioning finishes. Per-environment values belong in .bicepparam files rather than in a long chain of -p flags. One honest limitation: what-if sometimes reports changes that are not real, because a resource provider can echo a property back in a slightly different shape than the one you wrote. Treat a surprising ~ line as something to go and check, not as proof that Azure is about to change something.

terminal
$ az deployment group what-if -g app-rg -f infra/storage.bicep -p name=contosodata
Resource and property changes are indicated with these symbols:
+ Create
The deployment will update the following scope:
Scope: /subscriptions/0b1f8c2e-…/resourceGroups/app-rg
+ Microsoft.Storage/storageAccounts/contosodata [2023-05-01]
location: "westeurope"
sku.name: "Standard_ZRS"
properties.allowBlobPublicAccess: false
properties.minimumTlsVersion: "TLS1_2"
Resource changes: 1 to create.
$ az deployment group create -g app-rg -f infra/storage.bicep -p name=contosodata \
--query "{state: properties.provisioningState, blob: properties.outputs.blobEndpoint.value}"
{
"state": "Succeeded",
"blob": "https://contosodata.blob.core.windows.net/"
}
# Run it again — nothing to change, nothing breaks. Idempotency in practice:
$ az deployment group what-if -g app-rg -f infra/storage.bicep -p name=contosodata
Resource changes: 1 no change.
Bicep deploy loop
1Bicep in git
PR-reviewed
2what-if
preview the diff
3ARM control plane
incremental deploy
4Converged resources
same result every run
There is no state file to manage. ARM compares your template against the live resources at deploy time, which is what makes a re-run safe.

Modules: a paved road instead of ten snowflakes

A module is a Bicep file that another Bicep file calls, the same don't-repeat-yourself move as pipeline templates, pointed at infrastructure instead of build steps. The real payoff shows up when a platform team publishes vetted modules to a registry. Modules are stored as OCI artifacts (Open Container Initiative, the same packaging standard container images use) inside Azure Container Registry, and you reference them with br: plus a version tag. A hardened storage module can bake in private networking, diagnostic settings and tagging, so every team that consumes it provisions correctly without thinking about it. Microsoft maintains Azure Verified Modules (AVM) in the public br/public: registry, a tested baseline worth reaching for before you write your own. Pinning a version costs you something in return: upgrades become deliberate. Bumping v1.2.0 to v1.3.0 happens in a pull request (PR), and what-if shows the blast radius before anyone approves it.

main.bicep
// Consume a vetted module instead of hand-rolling the resource
module sa 'br/public:avm/res/storage/storage-account:0.14.3' = {
name: 'storageDeploy'
params: {
name: 'contosodata'
skuName: 'Standard_ZRS'
allowBlobPublicAccess: false
}
}
// Platform teams publish hardened modules to a private ACR registry:
// az bicep publish --file modules/storage.bicep \
// --target br:contosoacr.azurecr.io/bicep/storage:v1.2.0
// Consumers then reference 'br:contosoacr.azurecr.io/bicep/storage:v1.2.0'

Running the loop from Azure Pipelines

In a pipeline those same verbs become two stages. A *preview* stage prints the what-if diff for a human to read, and a *deploy* stage sits behind an environment approval, the gate that *Release pipelines & approvals* builds on. Authenticate with a service connection that uses workload identity federation: at run time Azure DevOps trades a short-lived OIDC token (OpenID Connect, a standard way for one system to prove its identity to another) with Entra ID, so there is no client secret sitting in a variable group waiting to be rotated or leaked. Scope that identity's Contributor role to the target resource group, never to the whole subscription. An infrastructure pipeline is a high-value target for exactly the reason it exists: it holds write access to production by design.

azure-pipelines.yml
trigger:
branches: { include: [main] }
paths: { include: [infra/*] }
stages:
- stage: Preview
jobs:
- job: whatif
pool: { vmImage: ubuntu-latest }
steps:
- task: AzureCLI@2
inputs:
azureSubscription: sc-infra-prod # workload identity federation — no stored secret
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az deployment group what-if -g app-rg \
-f infra/storage.bicep -p name=contosodata
- stage: Deploy
dependsOn: Preview
jobs:
- deployment: converge
pool: { vmImage: ubuntu-latest }
environment: production # approval gate is configured on the environment
strategy:
runOnce:
deploy:
steps:
- checkout: self
- task: AzureCLI@2
inputs:
azureSubscription: sc-infra-prod
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az deployment group create -g app-rg \
-f infra/storage.bicep -p name=contosodata
# Trigger and watch from the CLI (azure-devops extension):
# $ az pipelines run --name infra-deploy --branch main -o table
# Run ID Number Status Pipeline Name Source Branch
# -------- ----------- ----------- --------------- ----------------
# 1284 20260714.3 notStarted infra-deploy refs/heads/main

Slots and sticky settings: the config that has to stay behind

Deployment slots are extra live copies of an App Service app, each with its own hostname and its own settings. The routine goes like this. Push new code to a staging slot, poke at it until you believe it, then swap. App Service copies the production slot's configuration onto the staging workers, restarts them, waits for every instance to answer on its warm-up path, and only then flips which slot receives real traffic. Users land on processes that are already warm, and a bad release swaps back at the same speed. The trap is configuration. App settings and connection strings travel with the code by default. If APPLICATIONINSIGHTS_CONNECTION_STRING or an ENVIRONMENT flag rides along through the swap, your fresh production build boots up wired to staging telemetry. Marking a setting as a slot setting makes it *sticky*, meaning it stays with the slot instead of following the code. A handful of things are always sticky and never swap: managed identities, custom domains, internet protocol (IP) address restrictions, and scale settings.

Settings whose names begin with WEBSITE_ configure the App Service platform itself, so they earn a second look. WEBSITE_RUN_FROM_PACKAGE tells a slot which deployment package to mount. When each slot points at its own package location, that setting has to be sticky or a swap serves the wrong build to production. Declare stickiness in Bicep with the slotConfigNames resource so it lives in reviewed code, not in a checkbox somebody ticked in the portal eight months ago.

app.bicep (excerpt)
resource staging 'Microsoft.Web/sites/slots@2023-12-01' = {
parent: app // the Microsoft.Web/sites resource
name: 'staging'
location: location
properties: { serverFarmId: plan.id }
}
// Sticky settings: these names stay with the SLOT during a swap
resource sticky 'Microsoft.Web/sites/config@2023-12-01' = {
parent: app
name: 'slotConfigNames'
properties: {
appSettingNames: [
'ENVIRONMENT'
'APPLICATIONINSIGHTS_CONNECTION_STRING'
'WEBSITE_RUN_FROM_PACKAGE'
]
}
}
terminal
# Set a staging-only value and mark it sticky in one command
$ az webapp config appsettings set -g app-rg -n contoso-web --slot staging \
--slot-settings ENVIRONMENT=Staging
[
{
"name": "ENVIRONMENT",
"slotSetting": true,
"value": "Staging"
}
]
# Swap: staging's warmed code takes production traffic; sticky settings stay put
$ az webapp deployment slot swap -g app-rg -n contoso-web \
--slot staging --target-slot production
# (exits 0 with no output — verify the app is serving)
$ az webapp show -g app-rg -n contoso-web \
--query "{state: state, host: defaultHostName}"
{
"state": "Running",
"host": "contoso-web.azurewebsites.net"
}
A portal click can quietly undo a control your code enforces
Bicep is the source of truth only while every change goes through it. The day someone flips allowBlobPublicAccess to true in the portal to unblock a partner, live state and the repo stop agreeing, and a control you believed you had is gone with no commit, no review and no alert. Two defenses. Run what-if against every environment on a schedule and alert on any diff that comes back non-empty. And for the resource groups holding your crown jewels, deploy with deployment stacks (az stack group create --deny-settings-mode denyWriteAndDelete --action-on-unmanage detachAll) so ARM itself refuses writes that did not come from the stack. The rule stays short: change the code, or do not change the resource.

Know where Bicep stops. It speaks ARM and nothing else, so it manages Azure and only Azure. Not your GitHub org, not your Datadog monitors, not the second cloud your data team quietly adopted last quarter. Having no state file is a genuine simplification, with nothing to lock, back up or leak, but it cuts both ways. Bicep cannot track resources it did not create, and it cannot plan deletions the way a stateful tool can. Deployment stacks are ARM's answer to that gap, and they are still maturing. When your estate spans clouds and SaaS (software as a service) products, or your team already thinks in plan and apply, the usual choice is Terraform, with its own state file, hundreds of providers, and a different pile of trade-offs. That is where the next lesson goes.

So what do you actually get out of Bicep compiling down to ARM JSON? IaC (infrastructure as code, meaning your servers and storage are described in files you review like software) that is native to Azure, with real types for resource properties and editor completion that knows what a storage account will accept. Ordering mostly sorts itself out: reference another resource's .id and Bicep infers the dependency, or spell it out with dependsOn. What you give up next to Terraform is multi-cloud reach, and for an Azure-only team that trade is usually fine.

Modules are what stop environments drifting apart: one storage module every app uses, one network module the platform team owns, so dev and prod differ in parameters rather than in hand-typed details. Running what-if in CI (continuous integration, the automated build that fires on every change) before the deploy gives reviewers something concrete to argue about. Pair all of it with Azure Policy, so even a badly written Bicep file cannot create public storage while a deny policy is in place.

what-if output is the reviewer's best friend. A PR that shows only Bicep source asks humans to run ARM in their heads. A PR with what-if output pasted into it shows the creates, the deletes and the property-level diffs in something close to English. Have the pipeline post that summary on every infrastructure PR and review quality climbs without anyone memorizing storage SKUs (stock keeping units, Azure's name for a resource's size and tier).

Try this

Write the smallest Bicep file that does something real: a storage account in a resource group, HTTPS only, minimum TLS version 1.2. Build it to JSON so you can see what ARM actually receives, deploy it to a throwaway lab resource group, then deploy the exact same file again and watch nothing happen. That second run is the whole point.

terminal
cat > main.bicep <<'BICEP'
param location string = resourceGroup().location
param name string
resource sa 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: name
location: location
sku: { name: 'Standard_LRS' }
kind: 'StorageV2'
properties: {
minimumTlsVersion: 'TLS1_2'
supportsHttpsTrafficOnly: true
allowBlobPublicAccess: false
}
}
BICEP
az bicep build -f main.bicep
az deployment group create -g rg-lab-bicep -f main.bicep -p name=contosolabbicep$RANDOM
output
$ az deployment group create -g rg-lab-bicep -f main.bicep -p name=contosolabbicep9
"provisioningState": "Succeeded"
# Sample output on second run — no destructive recreate:
"provisioningState": "Succeeded"

Takeaway

ARM is the engine; Bicep is the language you write for it. Declarative plus idempotent is what makes it reasonable to hand a pipeline write access to production and still sleep.

Next: split the template into modules for network, data and app, pass parameters in from the pipeline, and never leave a secret sitting in a parameter default where az bicep build will print it straight back to you.

Quick check
01You push a new build to the staging slot and swap it into production. The staging slot has APPLICATIONINSIGHTS_CONNECTION_STRING set, and nobody marked it as a slot setting. After the swap, which Application Insights instance does your new production build report to?
Correct — App settings and connection strings follow the code by default; marking one as a slot setting is what pins it to the slot.
Incorrect — No. That is the common misreading. Settings follow the code unless you explicitly mark them sticky.
Incorrect — No. A swap is not validated against configuration mismatches, so it goes ahead and carries the wrong value across.
Incorrect — No. Connection strings are not wiped. They swap along with the code and quietly point the new build at staging telemetry.
02An ARM (Azure Resource Manager) or Bicep deployment runs in incremental mode unless you say otherwise. What does switching it to complete mode do?
Incorrect — No. Deployment history is an audit trail, not a rollback target, and complete mode does not restore an earlier deployment.
Correct — Complete mode removes whatever the template does not mention, which keeps the group exact and wrecks your day if someone forgets a resource.
Incorrect — No. Deployment mode decides how one scope gets reconciled; it does not widen how many resource groups you target.
Incorrect — No. Complete mode still updates matching resources in place rather than recreating them.
03A resource group holds crown-jewel production resources, all of them defined in Bicep. You want Azure itself to refuse any change or deletion that did not come from the pipeline, a portal click included, rather than finding out about it afterwards. Which approach does that best?
Correct — A deployment stack lets ARM refuse writes and deletes that did not come through the managed deployment, so you prevent the change instead of reporting it.
Incorrect — No. Complete mode only undoes the change at the next deploy, well after the fact, and it will happily delete anything missing from the template.
Incorrect — No. A scheduled what-if spots drift after it has already happened. It cannot stop the change.
Incorrect — No. That is a blunt change that breaks legitimate access, and anyone who keeps write rights can still make the edit.

Related