ARM & Bicep
Azure-native IaC, modules, idempotency.
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.
// Declarative, PR-reviewed, idempotent Azure IaC@description('Globally unique, 3-24 lowercase alphanumerics')@minLength(3)@maxLength(24)param name stringparam location string = resourceGroup().locationresource sa 'Microsoft.Storage/storageAccounts@2023-05-01' = {name: namelocation: locationsku: { 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.
$ az deployment group what-if -g app-rg -f infra/storage.bicep -p name=contosodataResource and property changes are indicated with these symbols:+ CreateThe 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: falseproperties.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=contosodataResource changes: 1 no change.
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.
// Consume a vetted module instead of hand-rolling the resourcemodule 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.
trigger:branches: { include: [main] }paths: { include: [infra/*] }stages:- stage: Previewjobs:- job: whatifpool: { vmImage: ubuntu-latest }steps:- task: AzureCLI@2inputs:azureSubscription: sc-infra-prod # workload identity federation — no stored secretscriptType: bashscriptLocation: inlineScriptinlineScript: |az deployment group what-if -g app-rg \-f infra/storage.bicep -p name=contosodata- stage: DeploydependsOn: Previewjobs:- deployment: convergepool: { vmImage: ubuntu-latest }environment: production # approval gate is configured on the environmentstrategy:runOnce:deploy:steps:- checkout: self- task: AzureCLI@2inputs:azureSubscription: sc-infra-prodscriptType: bashscriptLocation: inlineScriptinlineScript: |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.
resource staging 'Microsoft.Web/sites/slots@2023-12-01' = {parent: app // the Microsoft.Web/sites resourcename: 'staging'location: locationproperties: { serverFarmId: plan.id }}// Sticky settings: these names stay with the SLOT during a swapresource sticky 'Microsoft.Web/sites/config@2023-12-01' = {parent: appname: 'slotConfigNames'properties: {appSettingNames: ['ENVIRONMENT''APPLICATIONINSIGHTS_CONNECTION_STRING''WEBSITE_RUN_FROM_PACKAGE']}}
# 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"}
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.
cat > main.bicep <<'BICEP'param location string = resourceGroup().locationparam name stringresource sa 'Microsoft.Storage/storageAccounts@2023-01-01' = {name: namelocation: locationsku: { name: 'Standard_LRS' }kind: 'StorageV2'properties: {minimumTlsVersion: 'TLS1_2'supportsHttpsTrafficOnly: trueallowBlobPublicAccess: false}}BICEPaz bicep build -f main.bicepaz deployment group create -g rg-lab-bicep -f main.bicep -p name=contosolabbicep$RANDOM
$ 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.
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?