Same job, different philosophy
Ada’s colleague creates the test storage account. Ada’s configuration describes the same account, but her local records do not say it already exists. “Why is it trying to create another one?” she asks. Terraform needs a record connecting the resource in configuration to the resource in Azure. That record is state. Once two engineers use it, keeping that record shared and protecting simultaneous changes becomes part of the job.
- Terraform state
- Terraform's own record of the resources it manages — the mapping from each resource block in your code to the real cloud resource it created, with its last-known attributes. Code says what you want; the cloud is what exists; state is how Terraform knows which is which.
Why carry that third thing at all? Because it buys Terraform its defining trait: it is not married to Azure. ARM can be the bookkeeper for Azure resources because ARM is Azure. Terraform manages Azure, AWS, GCP, GitHub, and a few thousand other platforms through plug-in providers, and no single cloud can keep books for all of them — so Terraform keeps its own. The price of that independence is that the books must be looked after. Lose the state file and Terraform forgets everything it built; corrupt it and Terraform's picture of the world goes wrong in ways that surface as baffling plans. Most of what beginners find strange about Terraform stops being strange the moment you hold one sentence: there are three things — the code, the cloud, and the ledger between them.
The anatomy of HCL
Terraform files are written in HCL — HashiCorp Configuration Language — and once you have read Bicep, most of it is recognisable at a squint. Here is the Campux VNet from Class Ten, deliberately the same network you wrote in Bicep last class, so the comparison is felt rather than read.
# main.tf — the same VNet, second language
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.0"
}
}
}
provider "azurerm" {
features {}
}
variable "resource_group_name" {
type = string
}
data "azurerm_resource_group" "platform" {
name = var.resource_group_name
}
resource "azurerm_virtual_network" "hub" {
name = "vnet-hub"
location = data.azurerm_resource_group.platform.location
resource_group_name = data.azurerm_resource_group.platform.name
address_space = ["10.20.0.0/16"]
}
output "vnet_id" {
value = azurerm_virtual_network.hub.id
}
- provider
- The plug-in that teaches Terraform a platform's API — azurerm for Azure resources, with siblings for Entra ID, AWS, GitHub. Pinning its version is not optional politeness; providers change behaviour between major versions.2
- resource
- A thing Terraform creates and manages — a type, a local name, and the desired settings. The Bicep resource block wearing different clothes.
- data
- A thing Terraform reads but does not manage — here, an existing resource group. Data sources are how your code references infrastructure that belongs to someone else without taking ownership of it.
- variable / output
- Inputs you supply and values handed back — the same in-and-out flow as Bicep's param and output, so the mental model carries straight across.
The distinction worth pausing on is resource versus data. Marking something as a resource means Terraform owns it — it enters the state, and removing the block from your code means Terraform plans to destroy the real thing. A data source carries no such ownership. Mixing the two up is how teams accidentally schedule the deletion of a resource group somebody else built; the code reads almost identically, and the consequence does not.
The example already declares a resource_group_name variable. You can set input values with environment variables such as TF_VAR_resource_group_name, CLI arguments, or a terraform.tfvars file. Keep credentials out of values committed to Git. Here is a separate location example:
# variables.tf — declare the input; reference it as var.location variable "location" { type = string default = "eastus" } # terraform.tfvars — one line, and the default gives way location = "westus2"
State — where it lives, why it is sacred
With the default local backend, terraform apply writes terraform.tfstate beside your code. That is useful for a solo lab, but teammates need one protected, shared state for the same infrastructure. A remote backend stores it centrally. On Azure, a storage account can hold the state blob:
# backend.tf — the ledger moves to shared storage
terraform {
backend "azurerm" {
resource_group_name = "rg-tfstate"
storage_account_name = "campuxtfstate"
container_name = "tfstate"
key = "platform.tfstate"
use_azuread_auth = true
}
}
use_azuread_auth = true asks the backend to use Microsoft Entra identity for blob data. It does not grant access by itself: the learner or pipeline identity needs the Storage Blob Data Contributor role on the container or storage account. The AzureRM provider's permissions to manage Azure resources are separate from backend permissions to read and write state.
Two properties matter. Locking: the Azure blob backend takes a lease for an operation that could write state. A second operation cannot write concurrently; without a configured wait it may return a lock error, while -lock-timeout can wait for the lock. Confidentiality: state is JSON that can contain sensitive resource attributes. Keep state and its backups out of Git, restrict blob access, enable recovery controls on the storage account, and exclude *.tfstate, *.tfstate.*, and .terraform/ from commits. A remote backend can still write a local recovery copy if a state write fails.
Guard the ledger like a credential.
Play it through
Four minutes, two things to do. Two engineers are about to apply against the same VNet. Move the ledger into shared, locked storage before the second apply runs, then watch it read the same file instead of a different one. It plays on its own and stops when it needs your hands.
plan, apply, and drift
The working rhythm of Terraform is two commands. terraform plan is the sibling of Bicep's what-if: it refreshes its picture of the cloud, compares code against state against reality, and prints exactly what it intends to do. terraform apply does it. The symbols carry the same weight they did last class, plus one that is new and dangerous:
$ terraform plan
azurerm_virtual_network.hub: Refreshing state...
~ update in-place
~ resource "azurerm_virtual_network" "hub" {
name = "vnet-hub"
~ address_space = ["10.20.0.0/16"] -> ["10.20.0.0/20"]
}
Plan: 0 to add, 1 to change, 0 to destroy.
+ creates, ~ updates in place, - destroys — and a fourth, -/+, means destroy and recreate. Some properties cannot be changed on a live resource, so Terraform's honest plan is to delete the old one and build a replacement; for a stateless network that is an interruption, for a database it is data loss. Read every -/+ twice before you type yes.
A normal plan can also expose drift. Suppose someone changes a VNet tag in the portal. Terraform refreshes the VNet it manages, then may propose a change to return the tag to what the configuration declares. Read the refresh note and the proposed action separately. An empty plan is useful evidence for resources and attributes Terraform manages; it is not proof that every Azure resource or security setting in the subscription matches the repository. Unmanaged resources and ignored attributes need other checks.
init, destroy, and the graph you never draw
The working rhythm is four verbs, and you have already run two. init prepares a folder before anything else can happen: it reads your terraform block, downloads the providers your code names into a hidden .terraform directory, and wires up the backend from §3. You run it once per folder, and again whenever you add a provider or change where state lives — forget it and every other command refuses to start. apply builds. destroy is apply read backwards: it deletes exactly what state says Terraform owns, in the reverse of the order it built them, and asks first. For Campux that reverse order is not a nicety — a subnet cannot be removed while a VM still sits inside it, and Terraform tears down the VM first without being told to.
- init
- Prepares the folder — downloads the declared providers, configures the backend. Runs once per folder, and again after any provider or backend change. Nothing else runs until it has.
- plan
- The three-way comparison from §4, printed and nothing more. Changes no infrastructure; the safe command to run a hundred times a day.
- apply
- Applies a reviewed plan in dependency order, normally asking for confirmation. Other commands, including destroy and state operations, can also write; check the command and target before running it.
- destroy
- The ledger read in reverse — deletes what state owns, children before parents, after a confirmation. What it will remove is exactly what plan would have listed.
Knows to delete the VM first how? This is the quiet cleverness of the tool. When one resource references another's attribute — the VNet's id, the resource group's name — Terraform records that reference as an edge in a dependency graph and works out the safe order itself. You almost never write "build this before that." You write the references, and the order falls out of them. Declare a subnet that names its parent VNet and Terraform creates the VNet first, every time, across a hundred resources, without a single line of ordering from you. These are implicit dependencies, and they are most of the reason HCL reads as a description of a system rather than a script that assembles one.
- Implicit dependency
- An ordering Terraform infers because one resource's configuration references another's attribute. The reference both passes the real value along and tells Terraform which resource must exist first — so correct ordering is a free side effect of writing honest references.
Sometimes, though, A must exist before B and nothing in B's code mentions A. The textbook case: a role assignment has to be in place before an app can read a secret from Key Vault, yet the app's resource block never references the role assignment — the dependency is real in Azure and invisible in the code. For exactly that gap there is depends_on = [azurerm_role_assignment.app], which hands Terraform the edge it could not infer. Reach for it rarely and on purpose: most "I need depends_on" moments are really a missing reference you could add instead, and an honest reference is always the better fix because it carries a value as well as an order. An explicit depends_on that could have been a reference is a small confession that the code is not saying everything it knows.
Write the references; the order is Terraform's problem.
The blocks that separate the junior from the senior
Four constructs remain, and each is a place where the portal-trained engineer and the Terraform-fluent one visibly part ways. None is exotic; all of them decide how much breaks on an ordinary Tuesday.
count versus for_each
Both build many copies of a resource from one block; the difference is how Terraform remembers each copy, and that decides how much churns when the set changes. count indexes copies by number — subnet[0], subnet[1], subnet[2]. Delete the middle subnet from a list of three and every copy after it shifts index; Terraform, reading position as identity, plans to destroy and rebuild everything downstream of the gap. for_each keys copies by a stable string instead — subnet["web"], subnet["data"] — so removing one touches exactly that one and leaves its neighbours alone. For Campux's subnets, that is the difference between a plan that edits one line and a plan that proposes to rebuild the network.
| count | for_each | |
|---|---|---|
| Keyed by | Numeric position in a list | A stable string (a map or set key) |
| Remove the middle item | Everything after it reindexes and is rebuilt | Only that item is destroyed; the rest are untouched |
| Use when | Genuinely identical, positionless copies, or an on/off toggle | Almost always — anything that outlives a demo |
The one place count still earns its keep is a simple conditional — count = var.enabled ? 1 : 0 to include a resource or not. For a real set of things with names, the senior default is for_each, and the reasoning above is the answer an interviewer is fishing for when they ask why.
locals, and packaging with modules
Where a variable is an input from outside, a local is a value you compute inside and reuse — a naming convention assembled once and referenced everywhere as local.prefix, so the rule lives in one place rather than being retyped into forty resource names. When a resource group and a network want the same campux-prod stem, a local is how you say it once. And when a whole shape of infrastructure repeats — the three-copies rule from §5's cousin — you reach for a module: a folder of .tf files called as a unit, Class Twenty's Bicep modules in the other dialect.
# calling a local module — the network shape, packaged once, reused module "network" { source = "./modules/network" address_space = local.hub_cidr environment = var.environment } # the module hands values back the same way a resource does output "hub_vnet_id" { value = module.network.vnet_id }
Local modules live under a path in your own repository like ./modules/network; the public Terraform Registry offers thousands more, versioned and pinned exactly like providers — and owed exactly the same suspicion as any dependency you did not write. When a shop advertises a "module library," this is the noun: a vetted internal set that turns a new VNet into a ten-line module call. Arriving already fluent in the word is one of the cheaper good impressions available in an interview.
lifecycle — admitting what you do not fully own
Two settings inside a lifecycle block earn their place in memory. prevent_destroy = true makes Terraform refuse any plan that would delete this resource — the guard rail on Campux's production database, so that a stray -/+ aborts loudly instead of erasing the orders table. ignore_changes tells Terraform to stop fighting over an attribute that something else legitimately manages — a tag Azure Policy stamps on from Class Eight, an instance count an autoscaler moves — so plan stops proposing to revert a change that is supposed to happen. Both are honest admissions that not every attribute in the cloud is yours to own outright, and encoding that in the file is better than a morning plan that lies about drift which is not really drift.
import, and why provisioners are a last resort
Drill 02's amnesia — the resources still exist, the state forgot them — is undone by terraform import, which writes an existing Azure resource into state without recreating it, so Terraform adopts what is already there. It is also the on-ramp for a hand-built estate: rather than destroy and rebuild, you import each resource, then write matching HCL until plan comes back empty. Tedious, one resource at a time, and precisely the tax an organisation pays for infrastructure that was clicked into being before anyone wrote it down.
Which leaves the temptation to avoid. Terraform can be told to run a shell script on a machine it just built — a provisioner such as remote-exec or local-exec — and it is the wrong reach nine times in ten. A provisioner runs imperative steps that never appear in plan, never enter state, and are not undone cleanly on destroy, so it punches a hole in everything this class values: the reviewed diff, the honest ledger, the reversible teardown. Prefer the native path — cloud-init or a prebuilt image for machine setup, a real Azure resource for anything Azure already models, a configuration-management tool for what lives inside the VM. HashiCorp's own documentation names provisioners a last resort in those words; treat the phrase as the rule it is.
Bicep or Terraform — the interview answer
You will be asked, in interviews and in meetings, which one is better. The honest answer is that they are the same idea with one structural difference — who keeps the books — and a handful of consequences that follow from it.
| Bicep | Terraform | |
|---|---|---|
| Reach | Azure only | Azure, AWS, GCP, GitHub, and thousands more via providers |
| Bookkeeper | Azure itself — ARM compares your file to what exists | Terraform's own state file — a third artifact you must store, share, and guard |
| Preview | az deployment group what-if | terraform plan |
| New Azure features | Day one — Bicep is Azure's native surface | After the provider adds support; usually quickly, occasionally not |
| Operational cost | Nothing extra to run | The state backend: storage, locking, access control |
So the interview answer is not a side; it is a decision rule, delivered in about four sentences. Both are declarative, both preview before they apply, and I have written the same network in each. An Azure-only shop should lean Bicep: native, no state to operate, new features on day one. A multi-cloud shop, or one with an existing Terraform estate and module library, should lean Terraform: one language and one workflow across every platform, at the cost of operating state. The skill that transfers is infrastructure as code itself — the tool is a fitting. Candidates who say that get hired by both kinds of shop; candidates who declare one tool dead get filtered by whichever shop runs it happily at scale.3
One more noun from that answer earns a definition, because interviewers ask for it by name: modules. A Terraform module is a folder of .tf files invoked as a unit — Class Twenty's Bicep modules in the other dialect, with the same rule about when to extract: three copies, then a module, not before. Local modules live in your own repository under a path like ./modules/network; the public Terraform Registry offers thousands more, versioned and pinned like providers, and owed the same suspicion as any dependency you did not write. When a shop says it has a "module library", this is what it means — a vetted internal set that turns a new VNet into a ten-line module call — and arriving already knowing the word is one of the cheaper good impressions available.
The same VNet, twice on purpose
This class you rebuild a thing that already works, deliberately. The 10.20.0.0/16 VNet that Class Twenty wrote in Bicep gets written again as main.tf — same address space, same design, different language — not because Campux needs it twice, but because you need to feel where the two tools agree and where they part. The parting is state: Bicep needed nothing beyond the file, while Terraform will not take a step until you decide where its ledger lives.
So the first real Terraform work at Campux is not a resource at all. It is a rg-tfstate resource group holding a locked-down storage account, a tfstate container, and a backend "azurerm" block pointing at it — shared state, automatic lease locking, no .tfstate anywhere near Git. Forty stores do not care which language described their network. They care that on the day two engineers deploy at the same minute, the ledger refuses to be written twice.
The official Terraform and Azure state guides
What is Terraform on Azure?
learn.microsoft.com/azure/developer/terraform/overview
Store Terraform state in Azure Storage
learn.microsoft.com/azure/developer/terraform/store-state-in-azure-storage
The same VNet, second language
Start with local state to see what it records. Use a new lab-only group and check your active subscription. If Cloud Shell does not have Terraform, follow the linked Microsoft setup guide before starting. Keep this Cloud Shell session and folder for Lab Two.
Open Cloud Shell in Bash mode. Confirm the subscription before creating a uniquely named group and folder:
az account show --query "{name:name,subscriptionId:id}" -o table terraform version LAB_RG="rg-tf-lab-$(date +%s)" LAB_DIR="tf-lab-$(date +%s)" mkdir "$LAB_DIR" cd "$LAB_DIR" az group create --name "$LAB_RG" --location eastus export ARM_SUBSCRIPTION_ID="$(az account show --query id -o tsv)" export TF_VAR_resource_group_name="$LAB_RG" echo "$LAB_RG"What to notice: write down the printed group name and check that it is the subscription you intended. Work only in the new lab folder; later cleanup must name this same group.Create main.tf using the complete §2 VNet example. Its resource_group_name variable reads the value you exported above. Then initialise and validate:
terraform init terraform validate
On screen: init downloads the AzureRM provider. Commit the generated .terraform.lock.hcl in a real project; keep the downloaded .terraform/ directory out of Git.Preview, then apply:
terraform plan terraform apply
On screen: the plan should propose one VNet create. Read the name and group before typing yes at apply. A local terraform.tfstate file appears after a successful apply.Open the ledger and look at what it holds:
terraform state list terraform state show azurerm_virtual_network.hub
The lesson: Terraform maps the HCL address to a real Azure VNet. Use Terraform's state commands to inspect it; do not edit the JSON by hand or commit it. If you stop here, run terraform destroy and delete only your lab group.
Move the ledger, then catch a portal click
This is a controlled migration, not a fresh Terraform project. Keep Lab One's local state until the remote blob and its contents are verified. You need permission to assign yourself Storage Blob Data Contributor; if you do not have it, keep the local lab and destroy its VNet with local state instead of trying a half-complete migration.
Create a second lab-only group and storage account. Record both names:
STATE_RG="rg-tfstate-$(date +%s)" STATE_ACCOUNT="ctf$(date +%s)" echo "$STATE_RG $STATE_ACCOUNT" az group create --name "$STATE_RG" --location eastus az storage account create --resource-group "$STATE_RG" \ --name "$STATE_ACCOUNT" --location eastus \ --sku Standard_LRS --kind StorageV2 \ --allow-blob-public-access false
What to notice: Azure storage account names must be globally unique. If the name is taken, choose another lowercase letters-and-digits name. This is a billable account; keep it only for the lab.Give your signed-in user blob data access on this storage account, then create the private container with Entra authentication:
STATE_SCOPE="$(az storage account show \ --resource-group "$STATE_RG" --name "$STATE_ACCOUNT" --query id -o tsv)" USER_OBJECT_ID="$(az ad signed-in-user show --query id -o tsv)" az role assignment create --assignee-object-id "$USER_OBJECT_ID" \ --assignee-principal-type User --role "Storage Blob Data Contributor" \ --scope "$STATE_SCOPE" az storage container create --account-name "$STATE_ACCOUNT" \ --name tfstate --auth-mode login
What to notice: the role assignment can take several minutes to take effect. If container creation says access denied, confirm the assignment and retry later. If you cannot assign the role, stop the migration and use Lab One's local-state cleanup; do not substitute a key in the code.Back up the local state in this lab folder. Create backend.tf with this partial backend configuration; supply the two names at init time:
cp terraform.tfstate terraform.tfstate.pre-migration
terraform { backend "azurerm" { container_name = "tfstate" key = "lab.tfstate" use_azuread_auth = true } }terraform init -migrate-state \ -backend-config="storage_account_name=$STATE_ACCOUNT" \ -backend-config="resource_group_name=$STATE_RG"
What to notice: Terraform asks to copy the local state; confirm only if the destination is your new account. Keep the backup until the next step succeeds. Backend configuration and credentials can be cached under .terraform/, so never commit that directory.Confirm that Terraform still knows the VNet and the blob exists before discarding the local backup:
terraform state list az storage blob show --account-name "$STATE_ACCOUNT" \ --container-name tfstate --name lab.tfstate \ --auth-mode login --query name -o tsv
On screen: expect azurerm_virtual_network.hub and lab.tfstate. If either is missing, stop and recover with your local backup; do not delete the state account.Now create drift on purpose: in the portal, open vnet-hub and add a tag — say owner : tuesday-click. Then, back in Cloud Shell:
terraform plan
On screen: expect a refresh notice and a proposed ~ change to remove the unexpected tag. The provider's exact output can vary; inspect it rather than assume one line. Do not apply that plan just to prove the point.Review the destroy plan and remove only the VNet Terraform owns. Confirm state is empty before deleting either lab group; keep the backend until destroy has finished:
terraform plan -destroy terraform destroy terraform state list echo "$LAB_RG $STATE_RG" az group delete --name "$LAB_RG" --yes az group delete --name "$STATE_RG" --yes az group exists --name "$LAB_RG" az group exists --name "$STATE_RG"
What to notice: terraform state list should be empty after destroy, and both existence checks should return false. If destroy fails, keep the backend and local backup, fix the failure, and retry. Remove the sensitive local backup after you have confirmed cleanup.
Zoom out: state is a shared ledger, and shared ledgers need rules
You can point Terraform at a remote backend. Now reason about what state does as a system, because the ledger is now infrastructure as critical as anything it describes.
Local state is fine alone and corrupts the moment two people apply; the pain arrives exactly when the team grows enough to matter. What makes concurrent applies safe before that day, not after it?
Every apply depends on the remote state and its lock; the state file is now a piece of infrastructure whose loss is worse than any resource it tracks.
The ledger refusing to be written twice is the whole safety model; the constraint is agreement on where state lives and who may write it.
State that drifts from reality — a hand-edit in the portal — makes the next plan lie; the tool is only as honest as the state is current.
One state file for the whole estate becomes a single blast radius and a serialization bottleneck; splitting it by boundary is the scaling move.
The engineer who ships is asked "did it apply?" The engineer who gets promoted is asked "and what happens when two of you apply at once?" — and locked the ledger first.
A source of truth that is not memory
The company picks up a workload on another cloud, and now one tool has to describe both. You reach for Terraform, write the resources in HCL, run a plan so everyone can see the change before it happens, and apply. The state file becomes the team's shared source of truth — and "what is actually deployed?" has an answer that is not someone's memory.
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. State is the ledger between code and cloud: for every resource block, the real Azure id it corresponds to and the attributes it last saw. It is not your code — that lives in Git — not a command log, and not your login credentials, though it may incidentally contain secrets that resources generate, which is a different and important fact. Mistake state for a backup of the code and you will treat it casually; it is the opposite — the one artifact of the three that cannot be regenerated from the other two.
C. The resources still exist, but Terraform has lost the mapping from code addresses to their Azure IDs. A plan may propose creating resources again; applying without investigation could hit name collisions or create duplicates. First look for a trustworthy state backup or backend version. If none exists, import the existing resources into a new state and review the resulting plan. Do not apply an empty-state plan against a live environment.
Resource ids, attributes (secrets included), and dependency data. The two false options both belong to Git: the configuration lives in the repository, and so does its history — state holds neither. The middle true option is the one to sit with: state is plain JSON, and sensitive attributes land in it readably. That single fact drives half of this class's rules — state never enters Git, the backend storage account is locked down like a vault, and access to read state is access to secrets. If you picked the false options, you were treating state as a copy of the code; it is the opposite — a record of the cloud.
# backend.tf
1. terraform {
2. backend "azurerm" {
3. resource_group_name = "rg-tfstate"
4. storage_account_name = "campuxtfstate"
5. container_name = "tfstate"
6. key = "platform.tfstate"
7. access_key = "kW9xQ2v…rNb8w=="
8. }
9. }
Line seven. That access key is a credential with full control of the storage account holding your state — and state, as Drill 03 established, contains secrets. Committing it hands anyone with repository read access the keys to both. Once pushed it lives in Git history even after the line is deleted; the only real remedy is rotating the key.
The rest of the block is fine — names of resource groups and containers are not secrets (A), the key is just the blob's filename and needs no particular extension (B), and azurerm is exactly the backend this class configures (D). The fix is supplying the credential outside the file: the ARM_ACCESS_KEY environment variable, or better, an identity-based option from Class Nine so there is no key to leak at all. The same lesson as Class Twenty's drill, one tool over: shared code is the last place a secret should live.
The lock error shows protection working. The Azure backend leases the state blob during an operation that could write it. A second operation without a wait timeout can fail to acquire the lock; it must not bypass locking just to proceed. This protects state from concurrent writers, but it does not prove the first apply succeeded.
Check, then re-plan. Confirm whether the first apply completed and inspect the live resources and current state. The second engineer should run a fresh terraform plan and apply only if the new plan still represents the intended change. If the first run failed or left a stale lock, diagnose that separately before retrying; never override the lease on an active operation.
Then fix the process, not the tool. Two humans racing to apply from their own terminals is the real finding. The locking held, but it is the seatbelt, not the driving. The mature end-state — built over the next three classes — is that only the pipeline applies, one change at a time, from a reviewed plan. Locks should almost never be contested, because production should almost never have two writers.
Refuse the tribal framing — out loud. The question is baited: they told you their stack and then quoted people insulting it. Agreeing that Bicep is a dead end insults your interviewer's architecture; pandering that Bicep is superior everywhere is transparently what a candidate would say in that chair. The strong open names the structure instead: both tools are declarative infrastructure as code with a preview step, and the real difference is who keeps the books — Azure itself for Bicep, a state file you must operate for Terraform.
Then give the decision rule and apply it to them. An Azure-only shop leaning Bicep is making a sound call — native support, day-one feature coverage, and no state backend to run and guard. A multi-cloud shop, or one standardising a single workflow across platforms, earns Terraform's operational overhead. They are an Azure shop that has already chosen; the honest reading is that their choice fits the rule. Saying so is not flattery — it is showing you know why their stack is defensible, which is worth more than loyalty to any tool.
Close on the transferable layer. The habits this phase built — desired state in Git, a reviewed diff, a plan read before every apply — are identical in both languages, and you have now written the same VNet in each. A shop's tool can change in a quarter; an engineer who understands the model underneath is useful on either side of the change. That is the sentence that gets remembered after the interview.
Five things worth carrying out of this class
- There are three things, not two: the code (what you want), the cloud (what exists), and state — the ledger between them, which Terraform keeps because no single cloud can keep books for every platform it manages.
- HCL maps onto what you know: provider (the plug-in), resource (owned and managed), data (read but not owned), variable and output. The resource/data distinction is an ownership decision with destroy consequences.
- Team state lives in an Azure storage backend — shared, lease-locked against concurrent writes, and treated as a secret, because sensitive attributes sit in it as plain JSON. It never enters Git.
- terraform plan is the three-way comparison made visible: + creates, ~ updates, - destroys, -/+ destroys and recreates. A scheduled plan is a drift alarm; an empty one certifies reality still matches the repository.
- The interview answer is a decision rule, not a side: Azure-only leans Bicep (native, no state to operate), multi-cloud leans Terraform (one workflow everywhere, at the cost of running state). The transferable skill is the model, not the tool.
- Strictly, plan first refreshes state against the real cloud, then diffs code against the refreshed state — so the comparison is pairwise under the hood. The three-way picture is still the right mental model, because all three artifacts can independently disagree, and each pairing has its own failure story: code versus cloud is drift, state versus cloud is a stale or damaged ledger, code versus state is your pending change. ↩
- The azurerm provider is one of several for the Microsoft estate — azuread manages Entra ID objects, and azapi talks to the ARM REST surface of Class Sixteen directly, for features the main provider has not wrapped yet. Provider major versions genuinely change behaviour, which is why the version = "~> 5.0" pin matters; check the version current when you read this rather than trusting this page's number. ↩
- Treat the "industry standard" claims on both sides with suspicion. Terraform genuinely dominates multi-cloud job postings, Bicep genuinely dominates Azure-native shops, and both camps overstate their case; the ground also shifted when HashiCorp relicensed Terraform in 2023 and the OpenTofu fork appeared. What is settled is the direction: declarative IaC with a reviewed plan is the norm, and knowing two dialects of it makes you harder to filter out — whichever one a given shop runs. ↩
Run init, plan and apply on a small Terraform configuration, read the plan before you apply, and then move its state into Azure.
Play the lesson above to stamp this class.