IaCTOOL-SPECIFICCLOUD-SPECIFIC

The Plan: Desired vs Current

A plan is a diff between what the code says and what exists, classified into create, update, replace and delete — and every destructive line in it needs a human.

The question, the obvious approach, and why it breaks

Every lesson starts where the work starts: an operational problem, a first attempt that is entirely reasonable, and the way production disagrees with it.

The production question

What exactly is a plan telling me, and which parts of it must a person read before it runs?

The problem

An apply changes real infrastructure. Without a way to see the operations before they happen, every infrastructure change is discovered by performing it.

What teams do first

Read the plan output, see that it ends with "no errors", and apply. The tool computed it, so it must be right.

How it breaks

The plan is right and still catastrophic. "1 to add, 1 to destroy" is a perfectly correct plan for deleting the production database and creating an empty one (Destructive Changes: What a Rename Really Does).

How it breaks in production
  • The plan is right and still catastrophic. "1 to add, 1 to destroy" is a perfectly correct plan for deleting the production database and creating an empty one (Destructive Changes: What a Rename Really Does).
  • Long plans are not read. Past a few dozen resources people scroll to the summary line, which is exactly where the destroy count hides in plain sight.
  • The plan was computed against the world as it was minutes ago. If someone else applied in between, the plan you approved is not the plan that runs.
  • A plan computed on a laptop with a personal identity may differ from one computed by the pipeline, because permissions and provider versions differ.
  • Values not known until apply appear as unknown, and an unknown feeding a replacement decision means the plan genuinely cannot tell you whether something will be destroyed.
CodeBuildTestArtifactReleaseDeployRunObserveOperateIncidentRecoverLearnImprove

What is actually happening

Underneath the tooling, which is the part that survives a change of tool.

  • Refresh: read the live attributes of every resource the state knows about. Now the tool has "current".
  • Compare: for each resource address, compare current attributes to what the configuration asks for. Now it has a per-attribute diff.
  • Classify: no-op, update in place, replace (destroy then create, or create then destroy), create, delete. Replacement is chosen when a changed attribute is one the provider marks as forcing new — a decision made by the provider, not by you.
  • Order: derive the operation order from the dependency graph, including destroy order, which is the reverse.
  • Serialise: with Terraform, -out writes the classified operations to a file so apply executes exactly what was reviewed rather than recomputing. Without it, apply computes a fresh plan and the review referred to something else.

Reading the four operations

Every line in a plan is one of a small number of operations. Only two of them are dangerous, and one of those is the one that looks like an update.

The operation symbols, and which need a human
1 + create new resource safe to automate
2 ~ update in place provider patches the existing thing safe, usually
3 - destroy the resource goes away HUMAN
4-/+ destroy and then a changed attribute forces new; HUMANthis is
5 create replacement the old one is deleted first the dangerous one
6 <= read data source refresh only no-op
7
8Plan: 1 to add, 0 to change, 1 to destroy.
9 ^ the only place the destroy count appears in one line

The replacement operation is the one that reads as a change and behaves as a deletion. On a stateful resource it is not recoverable by any subsequent apply.

Plan, review, apply — as a gate rather than a step

The pipeline shape matters more than the tool. What makes this a control is that the thing approved and the thing executed are the same object, and that a destructive plan cannot proceed without a person.

The gate
  1. 1
    Plan in CI

    Compute the diff with the pipeline identity and pinned provider versions, and save it to a file.

    fails by Planning locally instead, so permissions and provider versions differ from apply.

    evidence The plan artifact is stored on the pipeline run with the commit sha.

  2. 2
    Classify

    Parse the plan's machine-readable output and count creates, updates, replaces and destroys.

    fails by Checking the source diff instead of the plan, which cannot see a replacement.

    evidence The counts are printed as a check status, not buried in a log.

  3. 3
    Policy check

    Reject changes that violate rules regardless of who approves — public exposure, missing encryption, deletion of a protected class of resource.

    fails by Policy that runs as advice rather than as a blocking check (Guardrails, Not Gates).

    evidence A deliberately non-compliant plan has actually been rejected.

  4. 4
    Human approval

    A named person approves when destroys or replaces are non-zero.

    fails by Approval fatigue from a plan that is destructive every time; fix the plan, not the gate.

    evidence The approval is recorded against the plan artifact, not against the pull request.

  5. 5
    Apply the saved plan

    Execute exactly the reviewed operations against a held lock.

    fails by State moved since the plan was computed, so apply refuses — which is the correct behaviour.

    evidence A follow-up plan is empty (A Successful Deploy Is Not Evidence of a Healthy System).

The ways a plan lies to you

TOOL-SPECIFICThe state-serial rejection described here is Terraform/OpenTofu with a locking backend. CloudFormation serialises at the stack level instead — a stack in an update-in-progress state rejects a second update outright, which is stricter and gives a clearer error at the cost of no concurrency at all.

A plan is an accurate answer to a question about a moment that has already passed. These are the specific gaps.

Plan-time uncertainty and what to do about it
TriggerSymptomCauseResponse
A value is computed by the providerAttributes show as unknown until applyThe value does not exist until the resource is createdAcceptable for outputs; not acceptable when the unknown feeds a replacement decision — split the apply so the value is known first
Someone applied between plan and applyApply fails on a state serial mismatch, or the saved plan is rejectedThe plan was computed against a state version that is no longer currentCorrect behaviour. Re-plan and re-review; do not force it
Console change during an incidentPlan shows changes nobody made in codeDrift — the code and reality diverged (Drift)Reconcile deliberately before applying anything else
Provider version bumpedA previously in-place update becomes a replacementThe provider changed which attributes force newPin providers, and read the plan after every provider upgrade as if it were a risky change
Resource changed outside the refresh windowPlan is empty, apply changes things anywayRefresh was skipped for speedDo not skip refresh in the pipeline; skipping it is a local-iteration convenience

How to do it properly

Most important first.

  • Always terraform plan -out=tfplan and terraform apply tfplan. The saved plan is the artifact under review; anything else means the reviewed plan and the applied plan are two different objects.
  • Post the plan to the pull request automatically and require an approval on it, not on the source diff (Review as a Gate, Required Checks).
  • Gate on the destroy and replace counts, not on the whole plan being clean. Machine-parse the plan and require an explicit human approval when either is non-zero (Policy as Code).
  • Run the plan with the same identity and provider versions the apply will use, in CI, not locally.
  • Reduce plan size until it is readable. A plan nobody reads is not a control.

How much can this affect

Every production change has a blast radius. Stated as a scale so it is comparable between changes rather than adjectival — and paired with what actually contains it, because a wide scope with a real containment mechanism is a different situation from a wide scope with none.

Blast radius if this is wrongEveryone
One testEveryone
What contains it

The human gate on destructive operations, provider-side deletion protection, and a restorable backup. The plan itself contains nothing — it is the thing being contained.

What can go wrong

Failure modes, including of the mitigation
  • Plan approved, someone else applies first, apply fails or applies a different diff. Locking prevents the corruption but not the surprise (State).
  • Auto-apply on merge with no destroy gate, which is fine for ninety-nine changes and removes a database on the hundredth.
  • Policy checks that run against the source rather than the plan, and therefore cannot see a replacement at all — the source diff for a catastrophic rename is one word.
  • Unknown values masking a replacement, so the plan says "known after apply" where the honest reading is "this may destroy something".
  • Reviewers habituated to large noisy plans, which is alert fatigue with a different output format (Alert Fatigue).
Misreads this invites
  • "The plan succeeded, so the change is safe." The plan succeeding means the diff was computable. It says nothing about whether the diff is one you want.
  • "No errors means no destroys." The summary line is the only place the destroy count appears in short form, and it is the line people skip.
  • "Plan then apply is a transaction." It is not. An apply can fail halfway, having created some resources and not others, and the state records exactly that partial reality.

Operating it

Evidence is the signal, not the intention. Rollback is sometimes 'you cannot, and that is the point'.

How you know it worked
  • The applied plan file is the same object that was approved, and its hash is recorded in the pipeline run.
  • A plan on the release branch with no changes reports no changes, so any diff you see is genuinely yours (Drift).
  • Every apply that destroyed or replaced anything has a named human approval attached to it.
How you get back
  • A plan is not applied yet, so discarding it costs nothing. This is the cheapest safety mechanism in the module and the most frequently skipped.
  • Once applied, the rollback story is whatever the operations were: creates can be destroyed, updates can usually be reverted, and replacements of stateful resources cannot be undone by any apply (Rollback: Only Useful If It Is Actually Safe).
What to automate, and what stays human
  • Automate: computing the plan, storing it, checking it against policy, counting destroys and replaces, and blocking on them.
  • Keep human: the approval on any plan containing a destroy or replace. This is the specific decision the spec insists stays with a person, because the tool cannot know whether the resource holds something irreplaceable (Manual Production Changes).
What this costs
  • Requiring approval on every destroy slows routine cleanup and trains people to approve reflexively if the volume is high. Keep the volume low by separating disposable infrastructure into its own state.
  • A saved plan expires: the longer between plan and apply, the more likely the world moved. Short-lived plans and locking are complements, not alternatives.
  • Machine-checking a plan means parsing the tool's JSON output, which is a schema you now depend on across upgrades.

Where this applies

This domain is unusually tool- and organisation-dependent. These labels say what each claim is specific to, and what a different platform, provider or organisation does instead.

  • TOOL-SPECIFICTerraform and OpenTofu compute the plan client-side and can serialise it with -out. CloudFormation change sets are created and executed server-side, and describe resource-level replacement without per-attribute detail. Pulumi previews by running your program in a dry-run mode, so a preview executes your code — side effects in that code run during preview.
  • CLOUD-SPECIFICWhich attribute changes force replacement is decided by the provider and mirrors the cloud API. The same logical change — renaming an instance, resizing storage — is in-place on one provider and a replacement on another, and can change between provider versions.

Where the depth lives

This domain teaches delivery and operation, and hands the mechanism off to the domain that owns it.

Domains that do not exist yet
  • Testing & Reliability Engineering — the plan is the closest thing infrastructure has to a test, and it is a static one.