Skip to content
CAMPUX Cloud Bootcamp Phase Three · Class Twenty-One
Phase Three — DevOps Core
4 drills · 2 situations · 2 Labs
Aligned to AZ-400
Class Twenty-One

Infrastructure as code: Terraform

Terraform compares your code with Azure and tracks which real resources it manages in state. Learn how to read a plan, protect that state, and move it safely when more than one engineer works on the same infrastructure.

Phase Three · DevOps Core · Class 21 of 42 · 29 min · Assumes Class 20

After this you can run init, plan and apply on a small Terraform configuration, read the plan before you apply, and then move its state into Azure.

New to this? Read Class 20 first. Already done it, or know the material? Carry on.

§1

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.

Figure 1 — The state file as a ledger sitting between the code and the cloud main.tf The code — what you want terraform.tfstate The ledger — what it remembers the third thing people forget exists Azure The cloud — what exists plan compares apply writes
Figure 1 — Code, cloud, and the ledger between Every Terraform decision is a three-way comparison. The code says what you want; the cloud is what exists; the state file records what Terraform believes it built and is the only link between the two. Beginners draw two boxes and wonder why Terraform behaves strangely — the strangeness almost always lives in the third one.
§2

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"
§3

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

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.

§4

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.

§5

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.

Later · §6

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.

Table 1 — Position is fragile identity; a name is a stable one
countfor_each
Keyed byNumeric position in a listA stable string (a map or set key)
Remove the middle itemEverything after it reindexes and is rebuiltOnly that item is destroyed; the rest are untouched
Use whenGenuinely identical, positionless copies, or an on/off toggleAlmost 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.

§7

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.

Table 2 — The same idea, two sets of books
BicepTerraform
ReachAzure onlyAzure, AWS, GCP, GitHub, and thousands more via providers
BookkeeperAzure itself — ARM compares your file to what existsTerraform's own state file — a third artifact you must store, share, and guard
Previewaz deployment group what-ifterraform plan
New Azure featuresDay one — Bicep is Azure's native surfaceAfter the provider adds support; usually quickly, occasionally not
Operational costNothing extra to runThe 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.

Case File · Campux Retail

The same VNet, twice on purpose

campux-platform gains a Terraform mirror and a state backend

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.

Fig. 2 · Terraform's first resource is where its ledger lives
two engineers, the same minute main.tf Terraform · the VNet in HCL terraform apply rg-tfstate tfstate container backend "azurerm" lease lock · not in Git campux-vnet 10.20.0.0/16 · subnets the managed resources state manages the lock refuses two applies at once
Where Bicep needed only the file, Terraform will not move until you decide where its state lives — so the first real work is not a resource but a backend: a locked-down storage account in rg-tfstate, a tfstate container, and lease locking, with no .tfstate anywhere near Git. That lock is the whole point: on the day two engineers apply in the same minute, the ledger refuses to be written twice.
Read · Microsoft Learn

The official Terraform and Azure state guides

Read the overview first; the state article is the one to work through slowly
Microsoft Learn · Docs

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

Lab 1 · init, plan, apply

The same VNet, second language

~15 minutes · Azure Cloud Shell in Bash mode · Terraform installed · a subscription that allows a VNet · billable resources

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
Lab 2 · Remote state & drift

Move the ledger, then catch a portal click

~20 minutes · same Cloud Shell session and folder · permission to assign a blob data role

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
Note · the storage account and VNet can incur charges; the amount depends on the subscription, region, and time used. If plan shows no tag drift, confirm the portal change saved and the account and subscription are the ones you created. Terraform may keep a local state recovery copy after a backend error; check the lab folder before leaving.
Think in systems

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.

Feedback loops

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?

Dependencies & coupling

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 constraint

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.

Second-order effects

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.

What breaks when it scales

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.

On the job

A source of truth that is not memory

You · Cloud Engineer · a multi-cloud ask

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.

Class Twenty-One

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.

Drill 01Recall · state
What does the Terraform state file actually record?
Marked

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.

Drill 02Recall · state
The state file for a working environment is deleted. The Azure resources themselves are untouched. What is Terraform's view of the world now?
Marked

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.

Drill 03Select three
Which three genuinely live in the Terraform state file?
Marked

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.

Drill 04Spot the error
This backend configuration is about to be committed to the repository. One line is a serious mistake. Which?
# 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.  }
Marked

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.

Situation 01Write before you reveal
Two engineers, working from the same repository, ran terraform apply at the same time. One apply failed with a state lock error and its owner is annoyed; a third teammate mutters that last year, before the remote backend, the same collision silently corrupted state and cost a weekend. You are asked to untangle what happened and say whether anything is actually wrong.
Ask what the lock error is evidence of — and what the alternative to that error would have been.
Reasoning

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.

Situation 02Write before you reveal
An interviewer leans back and asks: "We're a Bicep shop, but half the candidates tell us Terraform is the industry standard and Bicep is a dead end. What's your take?" How do you answer?
They are not asking which tool wins. They are testing whether you reason or recite.
Reasoning

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.

Examination record · first attempt
0/4
Class Twenty-One · Complete
Retain this much

Five things worth carrying out of this class

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
Notes
  1. 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. ↩
  2. 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. ↩
  3. 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. ↩