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)

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.

  1. Ask reality every time (stateless). This is ARM/Bicep (Phase 01). Each deploy is a PUT of 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.
  2. 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, plan can 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:

ArtifactWhat it isLives inAnalogy
Configurationthe world you want.tf / .tf.json / synthesized CDKTFthe desired end state
Statethe world Terraform remembers it builtterraform.tfstate (remote blob)its memory / model of reality
Planthe diff: config minus state, per resourcecomputed on plan, applied on applythe 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:

SymbolActionCondition
+createin config, not in state
~update in placein both, differs, only mutable attributes changed
-/+replacein both, differs, at least one immutable (ForceNew) attribute changed
-deletein state, not in config
(none)no-opin 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 (a lifecycle block): 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 use name_prefix. Many a create_before_destroy has 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.id in a config makes that resource depend on azurerm_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). A depends_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:

ChoiceMeaningWhen
Re-applyconfig wins; revert the out-of-band changethe portal edit was unauthorized/wrong; config is truth
Update config (or import)reality wins; bring config up to matchthe change was intentional and should be permanent
ignore_changesaccept ongoing drift on that attributesomething 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:

DimensionARM / Bicep (P01)Terraform (P02)
Modelstateless: idempotent PUT of desired state; RP is the source of truthstateful: diff config against a state file
State filenone — nothing to lose, store, or lockterraform.tfstate — must be stored, locked, protected
Driftrecomputed every deploy (no stale memory to drift)state can go stale; plan refreshes to detect it
ScopeAzure onlymulti-cloud / multi-provider (one graph spans clouds + SaaS)
Review artifactwhat-if (dry-run diff)plan (dry-run diff) — both are PR-time safety nets
Orderdependency-graph topo order; reverse for Complete-mode deletesdependency-graph topo order; reverse for all deletes
ReplaceRP decides; what-if shows itprovider ForceNew; -/+ in the plan
Ecosystemnative to Azure, first-class with new RP features4,000+ providers, but Azure features can lag the RP
Failure modeComplete-mode mass deletion (P01); async pollingstate 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):

  1. Resource + diff_attributes (Ch. 2–3). The model (address = type.name, attributes dict) and the leaf comparison: union of keys → (old, new) for changed/added/removed, omit identical. Validate both sides are dicts.
  2. plan_resource (Ch. 3–4). The four-way decision. current is Nonecreate; empty diff → no-op; any changed attr in force_newreplace; else update. The ForceNew boundary is the heart of the lab — test it both ways (force_new empty vs not) on the same diff.
  3. plan (Ch. 3, 5). Classify every config resource (look up its type's force_new set), mark addresses in state-but-not-config as delete, then order: creates/ updates/replaces in topological order, deletes in reverse. Don't emit no-op actions but count them in the summary. Reject duplicate addresses.
  4. 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).
  5. 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:

  1. Name Terraform's three artifacts (config, state, plan) and say exactly what apply does — and why ARM has no state file.
  2. Run the five-way diff in your head: given a config attr, a state attr, and a ForceNew flag, call create/update/replace/delete/no-op correctly.
  3. Explain the ForceNew boundary — why one immutable attribute turns the whole resource into a -/+, and what a -/+ destroys.
  4. Explain create-before-destroy vs destroy-before-create and the name-collision catch.
  5. Explain why creates go in topological order and deletes in reverse, with the subnet/NIC example.
  6. Explain remote state, the AzureRM backend, and the blob-lease lock — and why force-unlock is dangerous.
  7. Define drift (real ≠ state), describe plan's refresh phase, and list the three ways to reconcile it.
  8. Explain that CDKTF synthesizes to Terraform JSON and runs the same engine.
  9. Contrast Terraform's state model with ARM/Bicep's stateless PUT as 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 (ForceNew and 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.