Warmup Guide — Terraform, CDKTF & IaC State Internals
Zero-to-principal primer for Phase 02. Phase 01 built ARM's stateless deployment engine. This phase builds its stateful sibling: Terraform, whose entire behavior — the good and the terrifying — follows from one decision, to keep a state file. We go from "what is a state file" to "why does my plan show a
-/+that's about to delete my database," from first principles to the mechanism under the hood, with the diagrams, tables, and small code you need to defend every line in an interview and at 2 a.m.
Table of Contents
- Chapter 1: Why Terraform Has State (and ARM Doesn't)
- Chapter 2: The Three Artifacts — Config, State, Plan
- Chapter 3: The Plan Algorithm — The Five-Way Diff
- Chapter 4: ForceNew — Update vs Replace, the Load-Bearing Concept
- Chapter 5: The Dependency DAG — Order, and Its Reversal
- Chapter 6: Remote State and Locking
- Chapter 7: Drift — When State Lies
- Chapter 8: CDKTF — A Real Language, the Same Engine
- Chapter 9: Terraform vs ARM / Bicep — The Stateful/Stateless Tradeoff
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: Why Terraform Has State (and ARM Doesn't)
From zero. "Infrastructure as Code" means you describe the infrastructure you want in text, and a tool makes the cloud match it. There are two ways a tool can figure out what to change to make reality match your text.
- Ask reality every time (stateless). This is ARM/Bicep (Phase 01). Each deploy is a
PUTof desired state; ARM hands it to the resource provider, which compares your desired state to what it actually has and converges. There is no file recording what ARM "thinks" exists — the resource provider is the source of truth, queried fresh each time. Nothing to lose, nothing to drift, nothing to lock. - Remember what you made (stateful). This is Terraform. When Terraform creates a
resource, it writes an entry into a state file — a JSON document mapping your
config's address (
azurerm_storage_account.main) to the real resource's ID and attributes. Next time, instead of asking the cloud about everything, it diffs your config against this remembered state.
Why would anyone choose the harder, riskier stateful model? Because it buys things the stateless model can't:
- One graph across many clouds. ARM only knows Azure. Terraform's state can hold an Azure VNet, an AWS bucket, a Cloudflare DNS record, a GitHub repo, and a Datadog monitor in one dependency graph with one plan. The state file is what makes that universal — every provider records into the same format.
- A fast, reviewable plan. Because Terraform remembers,
plancan show you the diff without a full deploy, and you review it like a PR before anything changes. - Tracking resources the cloud can't uniquely identify by config alone — Terraform's state remembers which real resource corresponds to which config block, even when the config is a loop producing ten near-identical resources.
The cost is the rest of this phase: state can be lost, stale (drift), or contended (two people applying at once). Every famous Terraform horror story is one of those three. A principal doesn't fear state; they understand it as a separate, fragile artifact with its own lifecycle, and they protect it deliberately.
Production significance. "Where does your state live and how is it locked?" is the
fastest way to tell a Terraform team that's been burned from one that hasn't. The burned
team has remote state in an Azure Storage account with locking, restricted access, and
versioning. The other team has terraform.tfstate on someone's laptop and a story coming.
Common misconception. "State is just a cache; I can delete it and re-run." No — if you
delete state, Terraform forgets it owns your infrastructure. The next plan sees an
empty state and a full config and says "create everything" — against resources that already
exist, which then collide. Losing state is the worst non-data Terraform incident there is.
Chapter 2: The Three Artifacts — Config, State, Plan
From zero. Almost every confusing thing Terraform does becomes obvious the moment you hold these three apart:
| Artifact | What it is | Lives in | Analogy |
|---|---|---|---|
| Configuration | the world you want | .tf / .tf.json / synthesized CDKTF | the desired end state |
| State | the world Terraform remembers it built | terraform.tfstate (remote blob) | its memory / model of reality |
| Plan | the diff: config minus state, per resource | computed on plan, applied on apply | the changeset to reconcile them |
The relationship, as a diagram:
you write Terraform remembers
┌──────────────┐ ┌──────────────────────┐
│ CONFIG │ │ STATE │
│ (desired) │ │ (recorded reality) │
└──────┬───────┘ └──────────┬───────────┘
│ │
└──────────── diff ────────────┘
│
┌─────▼──────┐
│ PLAN │ create / update / replace / delete / no-op
└─────┬──────┘
│ apply
┌─────▼──────┐
│ NEW STATE │ (and real resources changed)
└────────────┘
terraform plan computes the diff and shows it. terraform apply executes the plan and
writes the new state. That's the whole loop. In the lab, config is a list of
Resource, state is a dict (address → attributes), and plan() produces the diff that
apply() executes into a new state dict.
Under the hood — what a real tfstate entry looks like:
{
"resources": [{
"type": "azurerm_storage_account",
"name": "main",
"instances": [{
"attributes": {
"id": "/subscriptions/…/storageAccounts/stappdata",
"name": "stappdata",
"location": "eastus",
"account_tier": "Standard"
}
}]
}]
}
The address azurerm_storage_account.main is type.name. The attributes block is the
recorded reality the next plan diffs against. Our lab's state["azurerm_storage_account.main"] = {"name": …, "location": …, "account_tier": …} is exactly this, with the envelope
stripped away.
Common misconception. "The plan and the apply are the same operation." They are not.
plan is read-only (modulo the refresh, Ch. 7); apply mutates. Best practice is to
plan -out=tfplan and apply tfplan so the thing you reviewed is exactly the thing that
runs — otherwise the world can change between plan and apply and you apply a different diff
than you approved.
Chapter 3: The Plan Algorithm — The Five-Way Diff
From zero. The plan is computed one resource at a time. For each resource Terraform compares the config version against the state version and emits exactly one action:
| Symbol | Action | Condition |
|---|---|---|
+ | create | in config, not in state |
~ | update in place | in both, differs, only mutable attributes changed |
-/+ | replace | in both, differs, at least one immutable (ForceNew) attribute changed |
- | delete | in state, not in config |
| (none) | no-op | in both, identical |
That five-way classification is the plan engine. Everything else — ordering, drift, rendering — wraps around it.
Under the hood — the algorithm in pseudocode (and exactly what plan_resource /
plan implement in the lab):
for each address in (config ∪ state):
desired = config.get(address) # None ⇒ removed from config
current = state.get(address) # None ⇒ never created
if desired and not current: → create
elif current and not desired: → delete
elif desired == current: → no-op
else:
changed = diff(desired, current) # attr → (old, new)
if any(attr is ForceNew for attr in changed): → replace
else: → update
The leaf primitive is the per-attribute diff (diff_attributes in the lab): walk the union
of keys; a key in both with different values is a change (old, new); a key only in config
is an add (None, new); a key only in state is a removal (old, None). The whole resource
decision rides on whether that change set touches a ForceNew attribute — Chapter 4.
Production significance. Reading a plan is this algorithm in your head. When you see
~ tags, you know "in both, mutable attr changed." When you see -/+ name, your stomach
should drop: an immutable attr moved, a real resource is about to be destroyed.
Common misconception. "No-op means Terraform did nothing useful." No-op means
converged — the most valuable plan in production is the all-no-op plan, the proof that
reality matches config. Re-running a converged config is all no-op, which is exactly
Terraform's idempotency (the lab's test_plan_idempotent_when_state_matches).
Chapter 4: ForceNew — Update vs Replace, the Load-Bearing Concept
From zero. Some attributes of a cloud resource can be changed in place; others cannot.
You can change a storage account's account_tier (Standard → Premium) — it's a mutable
property and the Azure API supports an update. You cannot change its location — a
storage account lives in one region forever; to "move" it you must delete it and create a
new one somewhere else. The provider schema encodes this: each attribute is marked
ForceNew: true (immutable) or not.
When a change touches a ForceNew attribute, an in-place update is physically impossible,
so Terraform plans a replace — shown as -/+ — which means destroy the existing real
resource, then create a new one. The blast radius is everything that resource held: the
data in the storage account, the IP address of the public IP, the identity of the database.
This is the single most important skill in the phase. A senior runs apply and trusts
the plan. A principal reads the plan for -/+ lines and asks "is this replace
acceptable, and is there data behind it?" — because the difference between ~ and -/+
on the same attribute name is the difference between a tweak and an outage.
Under the hood — why the boundary is exact. The lab's plan_resource makes the call:
delta = diff_attributes(desired, current) # {attr: (old, new)}
if not delta: return "no-op"
if any(a in force_new for a in delta): return "replace" # one immutable attr is enough
return "update"
One immutable attribute in the change set is enough to turn the entire resource into a
replace, even if nine other changed attributes are mutable. And the same diff is an
update if force_new is empty. That boundary is the lab's
test_plan_resource_force_new_boundary, and it's exactly the question an interviewer
probes: "this plan shows a replace — which attribute caused it?"
Replace ordering — destroy-before-create vs create-before-destroy. A replace is two operations, and their order is a downtime decision:
- Default: destroy-then-create. Terraform destroys the old resource, then creates the new one. There is a window where the resource doesn't exist — downtime, and if the create fails you're left with nothing.
create_before_destroy = true(alifecycleblock): create the new resource first, cut traffic over, then destroy the old. Zero (or near-zero) downtime. The catch: the new and old resources coexist briefly, so any attribute that must be globally unique (a name, a fixed IP) will collide unless you let it be generated or usename_prefix. Many acreate_before_destroyhas failed on a hard-coded name.
default (destroy-then-create): [destroy old] ──► [create new] ← outage window
create_before_destroy: [create new] ──► [cut over] ──► [destroy old] ← no gap
Production significance. "Change the VM size" can be an update on some SKUs and a
-/+ on others; "rename a resource" is almost always a -/+; "tighten a name to satisfy a
new policy" can quietly destroy a stateful resource. The plan tells you which — if you
read it. The famous incident shape is approving a plan whose -/+ on a database you
skimmed past.
Common misconception. "Terraform updated my resource, so my data is fine." Only if it
was a ~. A -/+ destroyed and recreated it — the data is gone unless it lived
elsewhere (a managed disk you detached, a backup you restored). Update and replace are not
two flavors of the same thing; one preserves the resource, the other annihilates it.
Chapter 5: The Dependency DAG — Order, and Its Reversal
From zero. Resources depend on each other: a subnet needs its VNet, a NIC needs its subnet, a storage container needs its storage account. Terraform must create them in an order that respects those dependencies, and — the part everyone forgets — destroy them in the opposite order.
Where the edges come from. Two sources, exactly as in ARM (Phase 01):
- Implicit (references). Writing
subnet_id = azurerm_subnet.main.idin a config makes that resource depend onazurerm_subnet.main— Terraform parses the reference and adds the edge. This is the common case and the reason you rarely write dependencies by hand. - Explicit (
depends_on). Adepends_on = [azurerm_role_assignment.x]block for ordering that isn't expressed through a reference (e.g. "this must exist after that for a reason the attributes don't capture").
Under the hood — topological order, and why it reverses for deletes. Terraform builds a directed acyclic graph (DAG) and walks it:
rg.main ──► sa.main ──► container.data (depends-on edges)
CREATE / UPDATE order (topological): rg.main → sa.main → container.data
DELETE order (REVERSE topological): container.data → sa.main → rg.main
Why reverse for deletes? Because you cannot delete a resource something else still depends
on. You can't delete a storage account while a container still lives in it; you can't delete
a VNet while a subnet still sits in it. So Terraform tears down dependents first,
walking the graph backwards. The lab encodes exactly this: creates/updates/replaces come
out in topological order, deletes in reversed(topological_order) — that's
test_plan_deletes_orphaned_state_in_reverse_order.
For determinism (so the lab is testable and a plan is reproducible) the topo sort uses Kahn's algorithm with a name tie-break: among resources that are all currently ready, emit the smallest address. Real Terraform parallelises independent resources; we serialise ties by name so the same config always yields the same plan.
The math. A valid plan order is any topological sort of the DAG ( G = (V, E) ), where
an edge ( a \to b ) means "(a) depends on (b), so (b) before (a)." A cycle in
(G) makes a topological order impossible — Terraform errors with Cycle: …. The number of
valid orders can be large; the tie-break collapses them to one canonical order.
Common misconception. "I'll just add depends_on everywhere to be safe." Over-using
depends_on serialises resources that could run in parallel (slower applies) and hides the
real reference graph. Prefer implicit dependencies (use the attribute reference); reach for
depends_on only when there's genuinely no reference to express the ordering.
Chapter 6: Remote State and Locking
From zero. If state lives as terraform.tfstate on your laptop, you are the only person
who can apply, and if your laptop dies the state dies with it. A team needs remote
state: the file lives in shared storage everyone can reach. On Azure that's the
AzureRM backend — a blob in a storage account:
terraform {
backend "azurerm" {
resource_group_name = "rg-tfstate"
storage_account_name = "sttfstate"
container_name = "tfstate"
key = "prod.terraform.tfstate"
}
}
The problem remote state introduces: concurrency. Now two engineers can both run
apply against the same state at once. Engineer A reads state, computes a plan, starts
creating resources, and writes state back — while Engineer B, working from the old state,
does the same. Their writes interleave; the state ends up describing neither reality. State
is corrupted.
Under the hood — the lock. Before apply (and other state-mutating operations),
Terraform acquires a lock. With the AzureRM backend the lock is an Azure Storage blob
lease on the state blob:
apply #1: acquire blob lease ── lease held ──► read state → plan → mutate → write state → release lease
apply #2: acquire blob lease ✗ "Error acquiring the state lock" (lease already held)
A blob lease is a short-lived exclusive claim on the blob; while A holds it, B's lock acquisition fails fast with a clear error instead of silently corrupting state. When A finishes (or is interrupted), the lease is released (or expires), and B can proceed. The lab doesn't simulate the lease — it's offline and single-threaded — but you must be able to explain it, because "how do you stop two people corrupting state?" is a stock interview question and the answer is "the backend's lock; on Azure, a blob lease."
Production significance. A locked, remote, versioned state backend is table stakes for team Terraform. The trio you want: remote (shared, durable), locked (no concurrent corruption), versioned + access-controlled (recover a bad apply, restrict who can read secrets that land in state). Note that last point: state often contains secrets (a generated password, a connection string) in plaintext — which is why the state blob must be encrypted at rest and access-restricted like the secret store it effectively is.
Common misconception. "terraform force-unlock fixes a stuck lock, so locking is no big
deal." force-unlock is a loaded gun: if you force-unlock while another apply is actually
running, you re-introduce the exact corruption the lock prevents. Use it only when you're
certain the lock is orphaned (a crashed run), never to "get past" a teammate's live apply.
Chapter 7: Drift — When State Lies
From zero. State is Terraform's memory of what it built, and memory goes stale. Someone opens the Azure portal during an incident and bumps a storage account's tier. Another tool changes a tag. A human deletes a resource by hand. Now the real world differs from what state records — that gap is drift, and "real ≠ state" is the one-line definition to memorise.
Why it matters. Terraform plans by diffing config against state. If state is stale,
the plan is computed against a lie — it might miss a change, or propose to "fix" something
that's actually correct. So before diffing, terraform plan does a refresh.
Under the hood — the refresh. terraform plan (and apply) runs in two phases:
1. REFRESH: for each resource in state, read its real attributes through the provider
(a GET against the Azure API) → update an in-memory copy of state to match reality
2. DIFF: compare CONFIG against the refreshed reality → produce the plan
So drift surfaces in the plan as changes you didn't write: you didn't touch the tier in
your .tf, but the plan shows ~ account_tier "Premium" -> "Standard" because someone
bumped it in the portal and refresh caught it. The lab's detect_drift(state, real) is a
pure model of the refresh-and-compare: it returns {address: {attr: (state_val, real_val)}}
for every recorded resource whose real attributes diverge — and an empty result when state
matches reality (test_detect_drift_none_when_in_sync).
Reconciling drift — three choices, and they're a decision:
| Choice | Meaning | When |
|---|---|---|
| Re-apply | config wins; revert the out-of-band change | the portal edit was unauthorized/wrong; config is truth |
Update config (or import) | reality wins; bring config up to match | the change was intentional and should be permanent |
ignore_changes | accept ongoing drift on that attribute | something else legitimately owns it (an autoscaler sets instance_count) |
A principal doesn't reflexively re-apply; they ask why the drift exists and choose. The worst outcome is a blind re-apply that reverts an emergency fix someone made in the portal at 3 a.m. for a good reason.
Production significance. Drift is the daily reality of any estate humans can also touch.
The mature answer is to reduce the surface: restrict portal write access, run a scheduled
plan that alerts on drift, and treat unexpected drift as an incident signal, not noise.
Common misconception. "Terraform manages everything, so there's no drift." Drift happens the moment anything outside Terraform can change a resource — a human, another pipeline, an Azure-managed mutation, an autoscaler. The question is never "will there be drift" but "do I detect it and have I decided how to reconcile each kind."
Chapter 8: CDKTF — A Real Language, the Same Engine
From zero. HCL (Terraform's config language) is declarative and pleasant for static infrastructure, but it's awkward when you want real programming: complex loops, conditionals, shared abstractions, unit tests, types. CDKTF (Cloud Development Kit for Terraform) lets you author infrastructure in a real language — TypeScript, Python, Go, Java, C# — using classes and loops and functions.
The crucial insight: CDKTF changes the authoring layer, not the engine. Here is the whole pipeline:
your Python/TS program ──cdktf synth──► cdk.tf.json (Terraform JSON) ──► the SAME plan/apply engine
cdktf synth synthesizes — it runs your program, which builds an object tree, and emits
Terraform JSON (cdk.tf.json), which is just Terraform configuration in JSON instead of
HCL. From there it's the identical engine you built in the lab: config → diff against
state → plan → apply, same ForceNew rules, same DAG, same state file, same locking. CDKTF
does not have its own state model or its own diff algorithm. It is a front-end that
produces config.
# CDKTF (Python): a loop that HCL would express clumsily
from cdktf import App, TerraformStack
from cdktf_cdktf_provider_azurerm.resource_group import ResourceGroup
class Spokes(TerraformStack):
def __init__(self, scope, id):
super().__init__(scope, id)
for region in ["eastus", "westus", "northeurope"]:
ResourceGroup(self, f"rg-{region}", name=f"rg-spoke-{region}", location=region)
App().synth() # → writes cdk.tf.json that `terraform plan` consumes
Production significance. Reach for CDKTF when the generation of config is the hard part — N near-identical spokes, config driven by data, abstractions you want to unit-test, teams who'd rather write Python than HCL. Stay with HCL when the infrastructure is static and the team values the simplicity and the enormous HCL ecosystem. Either way, the thing you learn in this lab — the plan/apply engine — is what runs underneath both.
Common misconception. "CDKTF is a different tool from Terraform." It's the same
Terraform engine with a programmatic front-end; debugging a CDKTF plan means reading the
synthesized cdk.tf.json and reasoning about it exactly as you would HCL. If you understand
the engine, you understand CDKTF for free.
Chapter 9: Terraform vs ARM / Bicep — The Stateful/Stateless Tradeoff
From zero. Phase 01 built ARM's engine; this phase builds Terraform's. They solve the same problem — make the cloud match your code — with opposite bets, and a principal can defend the choice between them with this table, not with taste:
| Dimension | ARM / Bicep (P01) | Terraform (P02) |
|---|---|---|
| Model | stateless: idempotent PUT of desired state; RP is the source of truth | stateful: diff config against a state file |
| State file | none — nothing to lose, store, or lock | terraform.tfstate — must be stored, locked, protected |
| Drift | recomputed every deploy (no stale memory to drift) | state can go stale; plan refreshes to detect it |
| Scope | Azure only | multi-cloud / multi-provider (one graph spans clouds + SaaS) |
| Review artifact | what-if (dry-run diff) | plan (dry-run diff) — both are PR-time safety nets |
| Order | dependency-graph topo order; reverse for Complete-mode deletes | dependency-graph topo order; reverse for all deletes |
| Replace | RP decides; what-if shows it | provider ForceNew; -/+ in the plan |
| Ecosystem | native to Azure, first-class with new RP features | 4,000+ providers, but Azure features can lag the RP |
| Failure mode | Complete-mode mass deletion (P01); async polling | state loss/corruption/contention; the dreaded -/+ |
The honest framing. Neither is "better." ARM/Bicep is stateless, Azure-native, and has no state file to lose — but it's Azure-only and you don't get a portable plan that also spans your DNS provider and your SaaS. Terraform gives you one reviewable plan across every provider on earth — at the cost of a fragile state artifact you must store, lock, version, and protect. The ADR that chooses between them weighs exactly those: "do we need multi-cloud / a unified graph?" (→ Terraform) vs "are we all-in on Azure and want to avoid state management entirely?" (→ Bicep). Many orgs run both: Bicep for Azure-native landing zones, Terraform where the graph crosses clouds.
Common misconception. "Terraform is more powerful because it has a plan and Bicep
doesn't." Bicep has what-if — the same idea. The real difference is the state model, and
it's a tradeoff (portability + a unified graph vs a fragile artifact), not a power ranking.
Lab Walkthrough Guidance
Lab 01 — Terraform State Engine, suggested order (each maps to a chapter):
Resource+diff_attributes(Ch. 2–3). The model (address=type.name,attributesdict) and the leaf comparison: union of keys →(old, new)for changed/added/removed, omit identical. Validate both sides are dicts.plan_resource(Ch. 3–4). The four-way decision.current is None→create; empty diff →no-op; any changed attr inforce_new→replace; elseupdate. TheForceNewboundary is the heart of the lab — test it both ways (force_newempty vs not) on the same diff.plan(Ch. 3, 5). Classify every config resource (look up its type'sforce_newset), mark addresses in state-but-not-config asdelete, then order: creates/ updates/replaces in topological order, deletes in reverse. Don't emitno-opactions but count them in the summary. Reject duplicate addresses.apply(Ch. 2). Execute the plan into a new state dict — create/update/replace write the desired attributes, delete removes the address — and never mutate the input (copy first; the test asserts the input is byte-for-byte unchanged).detect_drift(Ch. 7). For each address in both state and real, diff recorded vs actual; include only diverging addresses; empty result when in sync.
Run it red, make it green: pytest test_lab.py -v (28 tests). Then
LAB_MODULE=solution pytest test_lab.py -v to confirm the reference agrees, and
python solution.py to watch a worked plan (update vs replace) and a drift example print.
Success Criteria
You are ready for Phase 03 when you can, from memory:
- Name Terraform's three artifacts (config, state, plan) and say exactly what
applydoes — and why ARM has no state file. - Run the five-way diff in your head: given a config attr, a state attr, and a
ForceNewflag, call create/update/replace/delete/no-op correctly. - Explain the
ForceNewboundary — why one immutable attribute turns the whole resource into a-/+, and what a-/+destroys. - Explain create-before-destroy vs destroy-before-create and the name-collision catch.
- Explain why creates go in topological order and deletes in reverse, with the subnet/NIC example.
- Explain remote state, the AzureRM backend, and the blob-lease lock — and why
force-unlockis dangerous. - Define drift (real ≠ state), describe
plan's refresh phase, and list the three ways to reconcile it. - Explain that CDKTF synthesizes to Terraform JSON and runs the same engine.
- Contrast Terraform's state model with ARM/Bicep's stateless
PUTas a tradeoff.
Interview Q&A
Q: What are Terraform's three artifacts, and what does apply actually do?
Configuration is the desired world I write; state (terraform.tfstate) is the recorded
reality Terraform remembers it built — an address-to-attributes map; the plan is the diff of
config against state, per resource, in dependency order. terraform plan computes and shows
that diff (after a refresh); terraform apply executes the plan against the real cloud and
writes the new state. The whole tool is "reconcile config and state," and almost every
confusing behavior is explained by which of the three is stale or wrong.
Q: My plan shows -/+ on a resource. What does that mean and what do you do?
-/+ is a replace: a change touched a ForceNew (immutable) attribute, so Terraform
can't update in place — it will destroy the real resource and create a new one. First I
find which attribute caused it (a region, a name, a SKU that can't change in place). Then I
ask the load-bearing question: is there state or identity behind this resource? If it's a
stateless resource, fine. If it's a database, a storage account with data, or a public IP
something depends on, a blind apply is an outage — I'd reconsider the change, or use
create_before_destroy for zero-downtime where names don't collide, or move the data first.
The skill is catching the -/+ in review before anyone types yes.
Q: When is a change an update and when a replace?
It's an update (~) when every changed attribute is mutable — the provider's API can
modify it in place. It's a replace (-/+) the instant any one changed attribute is
marked ForceNew in the provider schema, because that attribute is immutable on the real
resource (you can't move a storage account's region). One immutable attribute in a sea of
mutable ones still forces a full replace. That boundary is exactly what the lab's
plan_resource decides.
Q: Why does Terraform destroy resources in reverse dependency order? Because you can't delete a resource something else still depends on. A storage container lives inside a storage account; a subnet inside a VNet; a NIC references a subnet. To tear down cleanly Terraform walks the dependency graph backwards — dependents first, their dependencies last — so nothing is deleted while something still needs it. Creates and updates go the forward (topological) way for the same reason: a thing is built only after what it depends on exists.
Q: What is drift and how does terraform plan surface it?
Drift is "real ≠ state" — the real resource diverged from what state records, because
something changed it outside Terraform (a portal edit, another tool, a human). plan runs a
refresh first: it reads each managed resource's real attributes through the provider and
updates an in-memory copy of state to match reality, then diffs config against that
refreshed reality. So drift appears in the plan as changes I didn't write. I don't blindly
re-apply — I decide: re-apply if config should win, update config/import if the change was
intentional, or ignore_changes if something else legitimately owns the attribute.
Q: How do you stop two engineers corrupting shared state?
Remote state with locking. On Azure the backend is an Azure Storage blob, and before any
state-mutating operation Terraform acquires a blob lease on the state blob — an exclusive
short-lived claim. A second concurrent apply fails fast with "Error acquiring the state
lock" instead of interleaving writes and corrupting state. I'd also version the blob (recover
a bad apply) and restrict access, because state often holds secrets in plaintext.
force-unlock exists for orphaned locks but is dangerous — using it against a live apply
re-creates the corruption the lock prevents.
Q: What does CDKTF change about Terraform, and what does it leave the same?
CDKTF changes the authoring layer: I write infrastructure in a real language (Python,
TypeScript, …) with loops, conditionals, classes, and tests, then cdktf synth to Terraform
JSON (cdk.tf.json). It leaves the engine completely unchanged — the synthesized JSON
feeds the exact same plan/apply, the same ForceNew replace rules, the same DAG, the same
state file and locking. CDKTF is a config generator, not a new model. So everything in this
lab is what runs under CDKTF too.
Q: Terraform or Bicep for a new Azure platform?
It's a tradeoff, not a ranking. If we're all-in on Azure and want to avoid managing a state
file at all, Bicep's stateless PUT model (with what-if for the dry-run) is simpler — no
state to store, lock, lose, or leak secrets through. If our graph spans clouds or SaaS, or
we want one reviewable plan across everything, Terraform's state model earns its keep despite
the fragility. Many orgs run both: Bicep for Azure-native landing zones, Terraform where the
dependency graph crosses provider boundaries. I'd write the ADR around "do we need a unified
multi-provider graph?" and "what's our appetite for managing state?"
References
- HashiCorp — Terraform: State (purpose, remote state, locking):
https://developer.hashicorp.com/terraform/language/state - HashiCorp — Resources: behavior & lifecycle (
create_before_destroy,prevent_destroy,ignore_changes):https://developer.hashicorp.com/terraform/language/resources/behavior - HashiCorp — Plugin SDK: schema behaviors (
ForceNewand how providers declare immutability):https://developer.hashicorp.com/terraform/plugin/sdkv2/schemas/schema-behaviors - HashiCorp — Backends: azurerm (Azure Storage state backend + blob-lease locking):
https://developer.hashicorp.com/terraform/language/settings/backends/azurerm - HashiCorp — Manage resource drift (refresh,
-refresh-only, reconciliation):https://developer.hashicorp.com/terraform/tutorials/state/resource-drift - HashiCorp — CDK for Terraform (synthesize to Terraform JSON, the same engine):
https://developer.hashicorp.com/terraform/cdktf - Terraform Registry — AzureRM provider (per-resource schema; which attributes are
ForceNew):https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs - Microsoft Learn — Store Terraform state in Azure Storage:
https://learn.microsoft.com/azure/developer/terraform/store-state-in-azure-storage - Microsoft Learn — Bicep vs Terraform / what-if (the stateless contrast):
https://learn.microsoft.com/azure/azure-resource-manager/bicep/deploy-what-if - The track's own CHEATSHEET.md and GLOSSARY.md.