Why write it down
For nineteen classes you have built Azure by clicking — a resource group here, a VNet there, a storage account with the right redundancy. It works, and it is exactly how you should have learned. But a portal click leaves almost no trace of intent: six months later, nobody can say why that subnet is a /24, whether the firewall rule was deliberate or a debugging leftover, or how to rebuild any of it if the region burns down. Infrastructure as code is the fix — you describe the desired state of your Azure resources in text files, commit them to the repository, and let a tool make reality match the file.1
- Infrastructure as code
- Defining your cloud resources in declarative text — versioned, reviewed, and deployed like software — so that the file is the source of truth and the running environment is its output, not the other way round.
State the payoff as the three things clicking fails to give you. No drift: when the file is authoritative, the environment cannot quietly diverge from what was agreed — and where it has, a deployment pulls it back. No archaeology: the "why" lives in the code and its Git history, so a decision made today is legible in a year instead of being reverse-engineered from a running system under incident pressure. Repeatability: the same file builds dev, test, and prod identically, and rebuilds any of them from nothing — the difference between a nine-hour recovery and a nine-minute one. Bicep is Azure's own language for this: a clean, declarative file that compiles down to the ARM JSON you met in Class Sixteen.2 The portal does not stop existing; it stops being the truth. The file is the truth, and the portal becomes one window onto it.
The anatomy of a Bicep file
A Bicep file has only a handful of moving parts, and once you can name them the language stops looking foreign. Here is a small but complete file — a storage account, parameterised — with every element you will use ninety percent of the time.
// main.bicep — a parameterised storage account
@description('Azure region for all resources')
param location string = resourceGroup().location
@allowed([ 'Standard_LRS', 'Standard_GZRS' ])
param redundancy string = 'Standard_LRS'
param namePrefix string
var storageName = '${namePrefix}${uniqueString(resourceGroup().id)}'
resource storage 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: storageName
location: location
sku: { name: redundancy }
kind: 'StorageV2'
properties: { allowBlobPublicAccess: false }
}
output storageId string = storage.id
- param
- An input you supply at deploy time — the thing that differs between dev and prod. Decorators like @allowed and @description constrain and document it.
- var
- A value computed inside the file — here, a name built from a prefix and a hash. Not an input; derived once, reused.
- resource
- The heart of it: a symbolic name, a type@apiVersion, and the properties that describe the desired state. This is the ARM resource of Class Sixteen, written cleanly.
- output
- A value the deployment hands back — an id, a hostname — so the next file or pipeline step can use it without guessing.
Notice what the file is: a declaration of the end state, not a script of steps. You do not tell Bicep "if the account is missing, create it, otherwise update the SKU" — you state the account you want and Bicep works out the difference. That declarative nature is the whole point, and it is why the same file is safe to run a hundred times. Notice too allowBlobPublicAccess: false sitting right there in the source: the security decision from Class Twelve is now reviewable in a pull request, not buried in a portal blade nobody revisits.
Modules — building bigger from small files
One file describing one storage account is a toy. Real infrastructure is dozens of resources, and cramming them into a single thousand-line file is the Bicep equivalent of a nine-hundred-line pull request — unreviewable and fragile. The answer is modules: a Bicep file can call another Bicep file the way a program calls a function, passing parameters in and receiving outputs back.
// main.bicep — composes reusable modules
param location string = resourceGroup().location
module network './modules/vnet.bicep' = {
name: 'networkDeploy'
params: {
location: location
addressSpace: '10.20.0.0/16'
}
}
module storage './modules/storage.bicep' = {
name: 'storageDeploy'
params: {
location: location
subnetId: network.outputs.appSubnetId // one module feeds the next
}
}
Read what that composition buys you. Each module is a small, self-contained file you can review, test, and reuse — the VNet module that builds Campux's 10.20.0.0/16 can be the same one a future project calls with different numbers. The network.outputs.appSubnetId line is the important trick: Bicep sees that storage depends on an output of network, so it works out the deployment order for you — network first, storage second — without you writing a single "do this before that" instruction. This is the same lesson as short-lived branches, one layer down: compose the system from small pieces that each fit in a reviewer's head, and let the tool handle how they join. A repository of well-named modules is what turns Class Seven's tenant sketch and Class Ten's VNet from diagrams into a platform that rebuilds itself on command.
what-if — the plan before the change
Declarative code has one frightening property: you describe an end state, hand it to Azure, and trust it to work out the steps. The first time you deploy a change to a live environment, "trust it" is not good enough — you want to see, before anything happens, exactly what will be created, changed, or deleted. Bicep gives you that preview with what-if.
$ az deployment group what-if \
--resource-group rg-campux-prod \
--template-file main.bicep
Resource changes: 1 to create, 1 to modify, 1 to delete.
+ Microsoft.Storage/storageAccounts/campuxlogs [create]
~ Microsoft.Network/virtualNetworks/vnet-hub [modify]
addressSpace.addressPrefixes: ["10.20.0.0/16"] => ["10.20.0.0/20"]
- Microsoft.Storage/storageAccounts/campuxtemp [delete]
Read that output like a pilot reads a checklist, because the symbols are the whole story: + creates, ~ modifies, and - deletes. That delete line is why what-if exists. A parameter typo that shrinks an address space or drops a resource looks harmless in the source and catastrophic in production, and this preview is where you catch it — before, not after. In a real pipeline the what-if runs automatically on every pull request and its output is posted for a human to read, so approving the PR means approving a named list of changes rather than a hopeful guess.3 It is the same instinct as reviewing a diff: never change what you have not first seen described.
Deployment scopes
One last thing decides where a Bicep file acts. A file that creates a storage account runs against a resource group; a file that creates the resource groups themselves has to run one level up. That level is the deployment scope, declared with targetScope and matched by the command you run.
The file is the truth.
| targetScope | What it can create | Command |
|---|---|---|
| 'resourceGroup' (default) | Resources inside one RG — VNets, storage, VMs | az deployment group create |
| 'subscription' | Resource groups themselves, and policy assignments | az deployment sub create |
| 'managementGroup' | Policies and subscriptions across many subs | az deployment mg create |
| 'tenant' | Management groups at the top of the hierarchy | az deployment tenant create |
The mental model is the hierarchy from Class Seven, now made operational: you deploy at a level to create things that live at that level. Most of your day is 'resourceGroup' — it is the default, so you rarely write it. You reach for subscription scope on the day you want the resource groups themselves in code rather than clicked into being, which is exactly what Build I asked you to govern by hand. A subscription-scope file can create an RG and, in the same deployment, hand off to a module that fills it — the whole environment, from empty subscription to running platform, described in one reviewable place. That is the destination of this class: not a storage account written in Bicep, but an entire Azure footprint that lives as text.
Clicks become code
The tenant design from Class Seven and the 10.20.0.0/16 VNet from Class Ten were, until now, decisions living in diagrams and portal blades. This class they become files. You write a vnet.bicep module for the address space and its app, data, and management subnets, a storage.bicep module carrying the Class Twelve rule allowBlobPublicAccess: false, and a main.bicep that composes them and passes one module's subnet id into the next. The Build I governance that was clicked into place now has a written form the team can review.
Every change goes through the flow of the last three classes: a short-lived branch, a pull request, and — wired in next class — a what-if posted for a reviewer before anything is applied. Campux can now rebuild its network from an empty resource group in minutes, and answer "why is this subnet a /24?" by reading a commit instead of guessing. The nine-hour outage of Class One had no such file to rebuild from; the Campux of Class Twenty does. That gap, written in Bicep, is the Build I artifact finally maturing from a governed subscription into a reproducible one.
The official module, and a CAMPUX overview
Build your first Bicep file
learn.microsoft.com/training/modules/build-first-bicep-file/
Microsoft Learn · Docs — What is Bicep?
learn.microsoft.com/azure/azure-resource-manager/bicep/overview
A short walkthrough of a Bicep file's anatomy, a module composing two resources, and a what-if reading before a deploy will live here. Video to be added.
From a file to a real resource
Write the storage account from §2 as a Bicep file, preview it with what-if, and deploy it for real. Use a throwaway resource group so cleanup is one command.
Sign in and make a throwaway resource group to deploy into:
az login az group create --name rg-bicep-lab --location eastus
What to notice: the resource group is your deployment scope. Everything this file creates will land inside it, so deleting the group at the end removes it all.Create main.bicep with the §2 storage account (a text editor, or the VS Code Bicep extension for autocomplete):
param location string = resourceGroup().location param namePrefix string var storageName = '${namePrefix}${uniqueString(resourceGroup().id)}' resource storage 'Microsoft.Storage/storageAccounts@2023-01-01' = { name: storageName location: location sku: { name: 'Standard_LRS' } kind: 'StorageV2' properties: { allowBlobPublicAccess: false } } output storageId string = storage.idWhat to notice: uniqueString makes the name globally unique from the RG id, so the file works without you hand-picking a free name.Preview before you change anything — run what-if:
az deployment group what-if \ --resource-group rg-bicep-lab \ --template-file main.bicep \ --parameters namePrefix=campuxlab
On screen: a single + create line for the storage account. Nothing exists yet — this is the plan, exactly what a reviewer would approve on a PR.Deploy it, then confirm the resource is really there:
az deployment group create \ --resource-group rg-bicep-lab \ --template-file main.bicep \ --parameters namePrefix=campuxlab az storage account list --resource-group rg-bicep-lab --query "[].name" -o tsv
The lesson: the account you described in text now exists in Azure. You did not click a single blade — the file was the instruction, and what-if showed you the consequence before it happened.
Run it twice, then change one line
Feel the two properties that make declarative code safe: deploying the same file changes nothing, and changing the file shows up in the plan before it is applied.
Deploy the unchanged file a second time:
az deployment group create \ --resource-group rg-bicep-lab \ --template-file main.bicep \ --parameters namePrefix=campuxlab
What to notice: it succeeds and creates nothing new. The file describes a desired state that already matches reality, so there is nothing to do. That is idempotency — the property that lets a pipeline redeploy without fear.Change one line in main.bicep — upgrade the redundancy:
// sku: { name: 'Standard_LRS' } -> sku: { name: 'Standard_GRS' }What to notice: a one-line edit is a reviewable diff. On a real team this goes through a PR, and the reviewer sees exactly this change to the redundancy of a production account.Preview the edit before applying — what-if again:
az deployment group what-if \ --resource-group rg-bicep-lab \ --template-file main.bicep \ --parameters namePrefix=campuxlab
On screen: now a ~ modify line, showing the SKU moving from Standard_LRS to Standard_GRS. The preview names the exact change — no surprises when you apply it.Apply the change if you like, then clean up everything in one command:
az group delete --name rg-bicep-lab --yes --no-wait
The lesson: same file, no change; changed file, a previewed change; and the whole environment removable in one line because it was disposable by design. Idempotency plus what-if is why teams trust a pipeline to deploy infrastructure unattended.
"Build me another one" becomes a parameter
"We need a staging copy of production by Thursday." Clicking it together would take days and drift immediately. You deploy the Bicep that already describes production, point it at a new resource group, and staging exists in minutes — identical, because it came from the same file. Infrastructure as code turns "build me another one" from a project into a parameter.
Examination
Four drills, then two situations. The situations have no marking scheme — write your answer before you reveal the reasoning, or the exercise is worthless. Nothing is stored; this is between you and the page.
B. An output hands a value back out of the deployment — most often an id or hostname the next module or pipeline step needs. A describes a param (an input); C describes a var (internal only). Confusing these three is what makes early Bicep feel random: param in, var within, output back out. Get the direction of data flow right and composition in §3 stops being mysterious.
C. In what-if output, + creates, ~ modifies, and - deletes. A delete against a production database almost never reflects real intent — it usually means the resource was dropped from the template, or a scope or name is wrong, and applying the deployment would destroy it. This is exactly the accident what-if exists to catch. You do not proceed; you find out why the file stopped describing something that should exist.
Repeatability, reviewable diffs, and intent captured in history. The two false options are seductive and wrong: IaC is often slower than a single click for a one-off change — its payoff is repeatability and review, not raw speed, so "faster than clicking" is the wrong reason to adopt it. And it certainly does not remove the need to understand Azure — you still have to know what a storage account or subnet is; you are just writing it down. IaC multiplies the knowledge you have; it does not replace it.
// storage-with-secret.bicep
1. param location string = resourceGroup().location
2. param sqlAdminPassword string = 'P@ssw0rd-Campux-2026'
3. resource sql 'Microsoft.Sql/servers@2023-05-01-preview' = {
4. name: 'campux-sql'
5. properties: { administratorLoginPassword: sqlAdminPassword }
6. }
Line two. A real password sits in plaintext in a file that is about to be committed to the repository — which means it is now in the Git history forever, visible to everyone with read access, and impossible to fully erase by simply deleting the line later. This is the Class Nine lesson returning: the credential is the asset, and IaC makes leaking one easier if you are careless, because the file is designed to be shared.
The fix is the @secure() decorator on the parameter, with no default value: @secure() param sqlAdminPassword string. That keeps the secret out of the file, out of logs, and out of what-if output — supplied at deploy time from Key Vault or the pipeline instead. Code that is meant to be reviewed and shared is the last place a secret should live.
Concede the true part before you argue. They are right about the moment: for one urgent change, clicking is faster than editing code and waiting on a pipeline. Denying that makes you sound like a zealot. The disagreement is not about the next thirty seconds — it is about the next six months, and saying so out loud earns you the room to make the case.
Name what the portal fix costs after the incident. A click fixes the symptom and leaves the file — the source of truth — now lying. The next deploy from that unchanged template will quietly revert the fix, or the drift will confuse the next person debugging at 3am. The thirty-second save today is borrowed against an afternoon of "why does prod not match the code?" later. IaC is slower per change and far cheaper per year; that trade is the whole point.
Offer the pragmatic path, not purity. If the fire genuinely demands it, click the fix now to stop the bleeding — then immediately reflect it back into the Bicep and open the PR, so the file and reality agree again before anyone forgets. The rule is not "never touch the portal"; it is "the code is the truth, so never let a portal change outlive the incident." Speed now, reconciled truth right after.
Praise the code, then separate it from the process. Open by acknowledging what is genuinely good — the resources are right, it deploys clean. That is real, and leading with it keeps the review from feeling like an ambush. The problem is not in the diff; it is in how the diff reaches production, and naming that distinction is the whole review.
Make the risk concrete, not abstract. Deploying straight from a laptop with no preview means a typo that shrinks an address space or drops a resource reaches production with nothing in between to catch it. Point at §4's delete line: the one time this matters, it matters catastrophically, and "it deployed fine on my machine" is exactly the story that precedes an outage. The gap is not hypothetical; it is the missing what-if.
Ask for the small change that closes it. Request — as a question, not a decree — that deploys run through the pipeline, with what-if on the PR and production behind an approval, the way the last three classes built. Frame it as protecting them: the day a bad template would have deleted a database, the preview is what saves their weekend and their reputation. Good code deployed recklessly is still a risk; the review's job is the whole path, not just the file.
Five things worth carrying out of this class
- Infrastructure as code makes the file the source of truth: no drift, no archaeology, and repeatable rebuilds. The portal becomes a window, not the record.
- A Bicep file is params in, vars within, resources declared, outputs back out — a declaration of desired state, not a script of steps.
- Modules compose small files into big systems; passing one module's output into another lets Bicep work out the deployment order itself.
- what-if is the plan before the change: + creates, ~ modifies, - deletes. Never apply what you have not first seen described — especially the delete lines.
- targetScope decides where a file acts — resourceGroup (default), subscription, managementGroup, tenant — matched by az deployment group/sub/mg/tenant create.
- There is a real distinction between declarative tools (Bicep, Terraform, ARM) that describe the desired end state and let the engine find the steps, and imperative scripts (a raw az or PowerShell sequence) that spell the steps out. Declarative is what makes idempotency and what-if possible, because the tool can compare "what you asked for" against "what exists". You will still write imperative scripts — Class Twenty-Five — but for standing infrastructure, declarative is the default for good reasons. ↩
- Bicep is a transparent abstraction over ARM: every Bicep file compiles to an ARM JSON template (az bicep build shows you the result), and anything ARM can express, Bicep can. That is worth knowing because older material and some Azure features are documented in ARM JSON first — being able to read both, and to see Bicep as the humane surface over the JSON of Class Sixteen, keeps you from being stranded when a sample is in the older syntax. ↩
- what-if is accurate but not infallible — for a few resource types and some property changes it can report a change that will not really happen, or occasionally miss a nuance, because it relies on each resource provider implementing the preview correctly. Treat it as a strong safety net, not a guarantee: it will catch the catastrophic delete you care about, but read a surprising result with judgement rather than blind trust. The direction — always preview before you apply — is not negotiable. ↩