Infrastructure Plan Review
Infrastructure Plan Review is the human gate that makes Infrastructure as Code (IaC) safe. Because the provisioning engine is diff-driven, running
plan(Terraform/OpenTofu), creating a change set (AWS CloudFormation), or runningwhat-if(Azure Resource Manager) produces a preview of exactly what would change without touching anything — and that preview, not the source code alone, is what a human reviews beforeapply. The reason this matters is subtle and load-bearing: the same code can produce a benign or a catastrophic plan depending on the current state of the world. A one-character change to a database’s engine version can turn a harmless in-place update into a destroy-and-recreate that deletes the data. Reviewing the code tells you the intent; reviewing the plan tells you the blast radius.plan“proposes a set of change actions that should, if applied, make the remote objects match the configuration” (Terraform plan docs); Azurewhat-if“predicts the changes if the specified template is deployed” without making them (ARM what-if docs); CloudFormation change sets “allow you to preview how proposed changes… might impact your running resources” (CloudFormation change sets docs). This note teaches how to read that diff, the workflow that posts it into a pull request (PR), the policy gate that runs against it, and the blast-radius discipline that bounds it.
Mental Model — Review the Diff, Not the Code
The instinct carried over from application development is “review the pull request’s code.” For IaC that instinct is necessary but insufficient. The code is a description of desired state; the plan is the description of the actual operations the engine derived by comparing desired state to observed reality. Two things break the naive code-only review:
- State-dependence. The plan is a function of (config, prior state, real infrastructure). Identical config yields different plans as the world changes underneath it. You cannot see a forced replacement by reading
.tf— only by reading the plan against today’s state. - Non-obvious replacement. Some attribute changes are updatable in place; others force the engine to “destroy and re-create resources whose arguments have changed but cannot be updated in-place due to remote API limitations” (Terraform resource behavior docs). Which is which is a provider decision invisible in the config.
flowchart LR subgraph inputs["What the engine compares"] CFG["Configuration<br/>(desired state)<br/>the PR's code"] PRIOR["Prior state<br/>(engine memory)"] REAL["Real infrastructure<br/>(live API refresh)"] end CFG --> DIFF{{"diff engine"}} PRIOR --> DIFF REAL --> DIFF DIFF --> PLAN["THE PLAN<br/>create · update · replace · destroy"] PLAN --> HUMAN["Human review<br/>(the gate)"] HUMAN -->|approve| APPLY["apply"] HUMAN -->|reject| BACK["fix code / state"] BACK -.-> CFG
What it shows and the insight to take: the plan is a derived artifact, downstream of three inputs, only one of which (the code) is visible in the PR by default. The whole practice of plan review exists to surface the other two — prior state and live reality — into the review, because that is where destroys hide. The gate is the human between plan and apply; remove it and you are applying blind.
Reading the Diff — Four Actions, Two Danger Signals
Every desired-state engine expresses its plan as a small set of per-resource actions. Terraform/OpenTofu uses four symbols (plan tutorial); the other tools use equivalent vocabularies. The skill of plan review is triaging these by danger.
| Symbol | Action | Meaning | Danger |
|---|---|---|---|
+ | create | a new resource is added | low — nothing existing is touched |
~ | update in place | attributes change, resource survives | low–medium — check which attributes |
-/+ | replace (destroy then create) | resource cannot be updated in place, so it is destroyed and recreated | HIGH — data/identity/endpoint loss |
- | destroy | resource is removed entirely | HIGH — is this intentional? |
The two lines you hunt for are -/+ (replace) and - (destroy). Everything else is usually routine; these two are where incidents live.
Replacement is the insidious one because it hides inside a change that looks like an update. Terraform annotates the offending attribute with # forces replacement. In this excerpt the engine has decided the change is destructive:
# aws_db_instance.main must be replaced
-/+ resource "aws_db_instance" "main" {
~ engine_version = "15.4" -> "16.1" # forces replacement
~ id = "db-OLD" -> (known after apply)
# (23 unchanged attributes hidden)
}
Plan: 1 to add, 0 to change, 1 to destroy.Read it carefully. The header says “must be replaced” and the line item is -/+ — this is not an upgrade, it is a delete of the existing database and creation of a fresh empty one. The # forces replacement marker on engine_version is the reviewer’s smoking gun: it says “this specific attribute is why the whole resource is being recreated.” The summary line 1 to add... 1 to destroy confirms a resource is dying. If aws_db_instance.main holds production data, approving this plan is a data-loss incident. The bottom-line summary — Plan: X to add, Y to change, Z to destroy — is the first thing to read on any plan; a non-zero destroy count on a stateful resource is an immediate stop-and-investigate.
The other engines speak the same language:
- CloudFormation change sets carry an
Action(Add,Modify,Remove,Import,Dynamic) and, critically, aReplacementfield (True,False,Conditional). The docs are explicit that the review purpose is to see “whether your changes will delete or replace any critical resources” before you “decide to execute the change set” (change sets docs).Replacement: Trueis CloudFormation’s-/+. - Azure what-if uses
- Delete,+ Create,~ Modify, plusDeploy,Ignore,NoChange, andNoEffect. ItsCreate/Deleteon the same logical resource is how you spot a replacement;Delete“only applies when using complete mode” (what-if docs) — a mode that deletes anything not in the template, an entire class of surprise-destroy on its own.
flowchart TD START["Read the plan"] --> SUM{"Summary line:<br/>any 'to destroy'?"} SUM -->|"0 destroy"| UPD{"Any '~' updates<br/>on sensitive attrs?"} SUM -->|"N destroy"| WHICH{"WHICH resources<br/>are '-' or '-/+'?"} WHICH -->|"stateless<br/>(instance, LB rule)"| MAYBE["Probably fine —<br/>confirm intent"] WHICH -->|"stateful<br/>(DB, volume, bucket)"| STOP["STOP.<br/>data-loss risk.<br/>investigate the<br/>'forces replacement' line"] UPD -->|"no"| OK["Approve"] UPD -->|"yes"| CHECK["Verify the attribute<br/>change is intended"] STOP --> FIX["Guard with prevent_destroy,<br/>use create_before_destroy,<br/>or redesign the change"]
What it shows and the insight to take: plan review is a triage decision tree, not a line-by-line read. Start at the summary, branch on destroy count, and spend your attention on -/-/+ against stateful resources — that is where 90% of the real risk concentrates. A hundred + creates deserve a glance; a single -/+ on a database deserves a meeting.
Catching it before it happens: the lifecycle guards
Two meta-arguments turn “hope the reviewer notices” into “the engine refuses.” prevent_destroy = true makes the engine “reject[] plans that would destroy the infrastructure object” (lifecycle docs) — apply this to every production database, stateful volume, and irreplaceable bucket, so a replacing plan fails to generate rather than waiting on human vigilance. create_before_destroy = true inverts the order so the replacement is built before the old one is torn down, eliminating the downtime window for resources that can tolerate two existing at once (an autoscaling launch config, say — but not a database with a unique name). These guards are defense-in-depth behind the review, not a substitute for it.
The Plan-on-PR Workflow — Code Review for Infrastructure
Plan review becomes a team practice by wiring it into the pull request. The pattern, sometimes called “GitOps for infrastructure,” is: plan on PR open, post the plan into the PR, review it alongside the code, apply on merge. A speculative plan — one run “without the -out option” — is exactly built for this: it is a preview that “cannot apply any changes,” so “developers can use speculative plans to verify the effect of their changes before submitting them for code review” (plan docs; HCP Terraform run docs).
sequenceDiagram participant Dev as Developer participant Git as Git / PR participant CI as CI runner<br/>(Atlantis / HCP TF / pipeline) participant Pol as Policy engine<br/>(OPA / Sentinel) participant Cloud as Cloud APIs Dev->>Git: open PR (propose .tf change) Git->>CI: webhook: PR opened CI->>Cloud: refresh + speculative plan (read-only) CI->>Pol: evaluate policy against the plan Pol-->>CI: pass / fail (deny-by-default) CI->>Git: POST plan diff as PR comment Note over Git: Human reviews the DIFF,<br/>not just the code Dev->>Git: request review / approval Git-->>Dev: approve (+ policy green + mergeable) Dev->>Git: merge / comment "apply" Git->>CI: trigger apply on the approved plan CI->>Cloud: apply (create/update/replace/destroy) Cloud-->>CI: result CI->>Git: POST apply result as comment
What it shows and the insight to take: the plan diff is injected into the code-review surface so the reviewer sees intent (code) and effect (plan) side by side, gated by machine policy before a human ever looks. Note the ordering: plan → policy → human → apply. Apply runs against the already-reviewed plan, not a fresh one, so what was approved is what executes.
Atlantis is the canonical open-source implementation of this loop. It triggers “commands via pull request comments”: atlantis plan “runs terraform plan on the pull request’s branch,” and atlantis apply “runs terraform apply for the plan that matches the directory/project/workspace” (Atlantis usage docs). It autoplans on modification, posts the output back into the PR, and enforces apply requirements (approved, mergeable) before it will let the apply run. Its PR-locking model ensures only one PR can hold a plan on a given project at a time, so two people cannot race conflicting applies.
HCP Terraform / Terraform Cloud implements the same shape as a hosted service: it “always plans first, then uses that plan’s output for the apply,” “waits for user approval before running an apply” by default, and runs VCS-triggered speculative plans on PRs that “show possible changes, and policies affected by those changes, but cannot apply any changes” (HCP Terraform run docs). The pipeline machinery that hosts any of these — runners, secrets, approval gates — is owned by Continuous Integration and Delivery MOC; this note owns the review semantics that ride on it, and the CI-specific mechanics live in the ghost sibling Infrastructure as Code in CI CD Pipelines.
Policy Checks at Plan Time — The Machine Reviewer
A human reviewer is fallible and does not scale; policy-as-code is the automated first reviewer that runs against the plan before the human sees it. Because the plan is a machine-readable artifact (Terraform can emit it as JSON), a policy engine can assert rules over the proposed actions: deny any plan that destroys a resource tagged protected, that creates a storage bucket without encryption, that opens a security group to 0.0.0.0/0, or that exceeds a cost threshold. HCP Terraform models this as a distinct policy check stage that sits “between planning and applying” where “policies like Sentinel can be evaluated before the apply stage proceeds” (HCP Terraform run docs).
The key design property is deny-by-default evaluated on the plan, not the code: the same reason humans review the plan (state-dependence, hidden replacement) means machines must too. A policy that reads only the .tf cannot see a forced replacement; a policy that reads the plan JSON can deny resource_changes[*].change.actions contains "delete" for any address matching a protected pattern. The depth of policy-as-code — the languages (Open Policy Agent’s Rego, HashiCorp Sentinel), Conftest, admission-style enforcement, where it sits in the supply chain — is owned by Policy as Code for Infrastructure and DevSecOps and Supply Chain Security MOC; here it is simply the machine reviewer standing in the same gate as the human.
Blast-Radius Awareness — Bounding the Damage
Even a perfectly reviewed plan can be too large to review well. The deeper discipline behind plan review is blast-radius control: structuring things so any single apply can only affect a bounded slice of the estate, so that both the plan and the potential damage stay comprehensible. A plan that touches four hundred resources across the whole organization cannot be meaningfully reviewed, and a mistake in it can take down everything at once. The remedies — splitting monolithic state by boundary, per-environment isolation, -targeted applies for surgical changes (used sparingly; the docs warn -target is “provided for exceptional circumstances… not recommended for routine operations” (plan docs)), and read-only credentials for the plan step so a compromised plan cannot mutate — are covered in depth by Blast-Radius Control for Infrastructure Changes and the general Failure Domains and Blast Radius principle. The connection to plan review is direct: the smaller the blast radius, the more reviewable the plan, and the less any one approval can destroy. Plan review and blast-radius control are two halves of the same safety property.
Failure Modes — How Plan Review Goes Wrong
- Rubber-stamping the code, ignoring the plan. The single most common failure: a reviewer approves because the HCL “looks right” and never reads the generated diff. The
-/+on the database was right there. Mitigation: require the plan output be posted to the PR and make its review explicit, not optional. - Reviewing a stale plan. A plan generated hours ago may no longer match reality if the world changed (someone did a console edit). Mitigation: apply the saved plan the review approved, and re-plan if too much time or drift has passed. Saved plan files also carry a hazard of their own — “if your plan includes any sort of sensitive data… it will be saved in cleartext in the plan file” (plan docs) — so treat them as secrets.
- Complete-mode / prune surprises. Azure’s complete mode deletes anything not in the template; a reviewer who does not know the deployment mode misreads the what-if. Mitigation: know the mode; prefer incremental unless prune is the explicit intent.
what-if/ plan noise. Engines report false diffs — Azure what-if “can’t resolve the reference function” and will report properties as changing when they will not (what-if docs); Terraform shows(known after apply)for computed values. Mitigation: learn your engine’s known noise so you do not chase phantom changes — and do not let noise-blindness hide a real change.- The plan that never destroys because it errors first. A change set “doesn’t guarantee that CloudFormation will successfully update a stack” (change sets docs); a green plan can still fail at apply on runtime constraints. Plan review reduces risk; it does not eliminate apply-time failure.
Alternatives and When to Choose Them
Plan review is a spectrum from “an engineer eyeballs terraform plan locally” to “fully automated policy-gated plan-on-PR.” The local eyeball is fine for a solo operator on a small estate; it does not scale to a team because there is no shared, durable, reviewable record and no lock against concurrent applies. The moment a second engineer joins, move to plan-on-PR (Atlantis, HCP Terraform, or a hand-rolled pipeline) so the plan is posted, reviewed, and locked. Add policy-as-code when human review alone stops catching everything — typically when the estate grows past what one person can hold in their head, or when compliance requires provable guardrails rather than hopeful ones. The continuously-reconciling flavor (Crossplane, GitOps controllers) shifts the review earlier still: you review the desired-state change, and a controller applies it — trading the explicit human apply-gate for continuous convergence, which is a different safety model (covered under Drift Remediation and Continuous Reconciliation).
Production Notes
The empirical case for plan review is the recurring “we changed one attribute and it recreated the database” incident, which every mature IaC shop has a version of. The pattern that prevents it in practice is layered: (1) prevent_destroy on every stateful resource so the engine refuses a destroying plan outright; (2) plan-on-PR so the diff is always posted and reviewed, never applied blind; (3) policy-as-code denying destroys on protected resources so the machine catches what the human misses; (4) split state so no single plan spans the whole estate. Note the theme — no single control is trusted; plan review is one layer in defense-in-depth, and its job is to make the dangerous actions (-/+, -) visible and gated, not to be the only thing standing between a typo and an outage.
See Also
- Plan Apply and Destroy — the three-verb lifecycle whose
planoutput this note teaches you to read - The Resource Dependency Graph — why an ordering of creates/destroys exists, and why a replace cascades
- Infrastructure State Files — the prior-state input that makes the plan state-dependent
- What Infrastructure as Code Provisions — the sibling §6 note; which resource categories are stateful and therefore replacement-dangerous
- Infrastructure as Code in CI CD Pipelines — the pipeline machinery (Atlantis / HCP Terraform / CI) that posts the plan to the PR (depth → Continuous Integration and Delivery MOC)
- Policy as Code for Infrastructure — the machine reviewer that evaluates policy against the plan (depth → DevSecOps and Supply Chain Security MOC)
- Blast-Radius Control for Infrastructure Changes — bounding how much one approved apply can destroy
- Failure Domains and Blast Radius — the general reliability principle behind blast-radius control
- Drift Remediation and Continuous Reconciliation — the continuous-reconcile alternative to the explicit apply-gate
- Configuration Drift and Drift Detection — why a plan can surprise you when reality has drifted from state
- Infrastructure as Code MOC — parent MOC (§7 Workflow and Collaboration)
- Continuous Integration and Delivery MOC · DevSecOps and Supply Chain Security MOC — sibling MOCs owning the pipeline and the policy/security interiors