A reviewer approving a Terraform pull request is certifying that an operation on real infrastructure is acceptable.
They cannot do that from the diff. The purpose of validation is to produce, automatically and reliably, the evidence that makes the certification possible — and then to put it somewhere they will actually read.
Most Terraform pipelines produce that evidence and then bury it in a CI log. This lesson is about the whole path, including the last step.
The validation ladder
Section titled “The validation ladder”Each rung costs more than the one before and catches things the earlier ones cannot. Run them in this order so cheap failures fail fast.
| Rung | Catches | Needs credentials? | Typical cost |
|---|---|---|---|
fmt -check | Formatting drift | No | Seconds |
validate | Syntax, types, references | No | Seconds |
| Lint | Deprecated usage, bad practice | No | Seconds |
| Security scan | Known-bad patterns | No | Seconds to a minute |
plan | The actual effect | Read-only | Minutes |
| Policy on the plan | Prohibited operations | No | Seconds |
| Cost estimate | Financial effect | Sometimes | Seconds |
The first four are static: they read files. The plan is different in kind — it contacts providers, refreshes state, and computes what would happen. That is why it is last among the expensive steps and why everything after it operates on its output.
Formatting
Section titled “Formatting”terraform fmt -check -recursive -diff-check exits non-zero without modifying anything; -diff shows what would change so the failure message is actionable.
Run it at the repository root with -recursive so a new directory is covered automatically rather than needing somebody to remember to add it to a list.
The pipeline fails; it does not fix. A workflow that runs terraform fmt and commits the result needs write access to branches, which is a permission granted to save a developer one command. Worse, it means the tree a reviewer approved is not the tree that was pushed.
The correct place to fix formatting is before the push. A pre-commit hook running terraform fmt costs nothing and removes the failure entirely.
Validation
Section titled “Validation”terraform init -backend=falseterraform validate-backend=false matters more than it looks: validation needs no state, so this step needs no backend credentials. One fewer place credentials appear, and it runs in seconds.
Be honest about the scope. validate confirms the configuration is internally coherent — arguments exist, types match, references resolve. It will not tell you a security group is too open, an instance type is unavailable in your region, or that a change destroys a database.
Linting
Section titled “Linting”Linting catches a class of defect between “syntactically valid” and “will do something bad”: deprecated syntax, provider arguments that were removed, unused declarations, missing version constraints, and naming inconsistency.
The value is highest in large repositories where nobody reads all of it, and where a deprecated argument sits unnoticed until a provider upgrade turns it into an error. It is cheap enough to run on every pull request and specific enough that its findings are usually actionable.
Configure it in a committed file so the rules are reviewable, and pin the linter’s version so a tool release does not fail everybody’s pull requests on a Monday morning. Version pinning matters more for linters than for most tools, because a new rule in a minor release applies retroactively to code that was compliant when it was written.
Security scanning
Section titled “Security scanning”Static analysis against the configuration catches known-bad patterns before anybody reads a plan: unencrypted storage, permissive ingress rules, public buckets, missing logging, over-broad IAM.
Three things determine whether it is useful or noise.
Baseline the existing findings. A scanner introduced to a mature repository reports two hundred issues. If they all block, the team disables the scanner within a week. Baseline what exists, block only new findings, and work the baseline down deliberately as separate pieces of work.
Allow documented exceptions, in the repository. Some findings are wrong for your context. An inline suppression with a reason is reviewable; a globally disabled rule is invisible.
Distinguish blocking from advisory. A storage bucket exposed to the public internet in a repository that never intends one is blocking. A missing cost-allocation tag is advisory. Treating them identically teaches people that the security check is something to be worked around rather than something to be read, and that habit does not distinguish between the finding that did not matter and the one that did.
The plan, and its output
Section titled “The plan, and its output”terraform initterraform plan -input=false -lock-timeout=5m -no-color -out=tfplanterraform show -no-color tfplan > plan.txtterraform show -json tfplan > plan.jsonFour outputs from one operation, each with a job:
tfplan — the binary plan, applicable later. Sensitive: it contains resource attribute values.
plan.txt — human-readable, for the pull request comment.
plan.json — machine-readable, for policy evaluation and for extracting counts.
Exit code — with -detailed-exitcode, 0 means no changes, 1 an error, 2 changes present.
Extracting the summary from JSON is more reliable than parsing text:
jq -r ' [.resource_changes[]? | select(.change.actions != ["no-op"])] as $c | "Add: \([$c[] | select(.change.actions | index("create"))] | length) " + "Change: \([$c[] | select(.change.actions == ["update"])] | length) " + "Destroy: \([$c[] | select(.change.actions | index("delete"))] | length)"' plan.jsonThat destroy count is the number the whole pipeline exists to surface.
One subtlety worth knowing: a replacement has the actions ["delete", "create"], so it counts in both the add and the destroy totals. That matches Terraform’s own summary line, which reports a single replacement as 1 to add, 0 to change, 1 to destroy. It also means a non-zero destroy count does not distinguish “this resource is going away” from “this resource is being rebuilt” — and those are very different conversations. Worth extracting replacements separately when the pull request touches anything stateful:
jq -r ' [.resource_changes[]? | select(.change.actions == ["delete","create"] or .change.actions == ["create","delete"]) | .address] | .[]' plan.jsonTerraform emits ["create","delete"] rather than ["delete","create"] when a resource has create_before_destroy set, which is why both orderings are matched above.
Plan output is sensitive
Section titled “Plan output is sensitive”The point teams miss until it costs them.
A plan contains the attribute values of the resources it describes. Those routinely include database connection strings, generated passwords, private keys and internal hostnames. Terraform marks some values sensitive and redacts them — but only those a provider or your configuration declared sensitive. Plenty of disclosure-worthy detail is not marked.
The consequences:
Never post a full plan on a public repository’s pull requests. A fork pull request from a stranger triggering a workflow that posts your infrastructure’s attributes is a disclosure with no attacker skill required.
Store plan artifacts with the same care as state. A tfplan in a public artifact is a secret leak.
Restrict who can read plans on private repositories in proportion to what they contain. Read access to a repository is often broader than read access to production secrets ought to be.
For public repositories, post the summary only. Counts, resource addresses, and whether anything is destroyed — with the full plan available to people with repository access through the run logs.
Surfacing it
Section titled “Surfacing it”The step that determines whether any of the above matters.
Post the plan as a pull request comment. Not a link to a log. Four clicks of friction is enough to make reviewers approve on the diff.
Update one comment rather than appending. Six plan comments from six pushes means the reader has to determine which is current. Editing a single sticky comment removes the ambiguity.
Lead with the counts, collapse the detail.
### Terraform plan — `environments/production`
**Add: 2 · Change: 1 · Destroy: 1**
> ⚠️ **1 resource will be destroyed:** `aws_db_instance.analytics`
<details><summary>Full plan</summary>
...
</details>Flag destroys in the visible part. A destroy on line 340 of a collapsed block is not surfaced.
Use -no-color. ANSI escapes render as noise in Markdown.
Truncate honestly. Comments have size limits. “Output truncated — full plan in the run log” is fine; a silently cut-off plan is not.
Large repositories
Section titled “Large repositories”A repository with thirty root modules cannot plan all of them on every pull request.
Detect changed root modules.
git diff --name-only "origin/${BASE_REF}...HEAD" \ | grep -E '\.(tf|tfvars|hcl)$' \ | xargs -r -n1 dirname \ | sort -uHandle shared modules, or the detection is a trap. A change to modules/network/ affects every root module consuming it, and path detection will report only the module directory. A pull request that appears to change nothing then changes everything. Two workable answers: maintain an explicit map of consumers, or plan everything whenever a shared path changes. The second is cruder and much harder to get wrong.
Fan out with a matrix, one job per root module, so failures are attributable and plans stay separate.
Aggregate into one comment. A table of which modules changed and their counts, with per-module detail collapsed. Reviewers triage from the table.
Cache providers. Downloading providers for thirty modules dominates the runtime otherwise, and the cache key should include the lock file so a provider upgrade invalidates it correctly.
Policy on the plan
Section titled “Policy on the plan”The strongest check available, because it evaluates the computed effect rather than the source.
terraform show -json tfplan produces a structured document a policy engine can evaluate. The rules worth writing are the ones that encode an operational fact rather than a style preference:
- No plan may delete a resource carrying a
protectedtag. - No plan may create a security group rule opening a port to
0.0.0.0/0. - No plan touching the production state may run outside the production environment job.
- No plan may create an unencrypted volume or storage bucket.
- A plan destroying more than n resources requires a second approval.
That last one is a shape worth borrowing generally: rather than blocking, escalate. A rule that adds a required reviewer when the destroy count is non-zero applies friction proportionate to risk, and does not train people to bypass it on routine changes.
Where the policy lives matters. Rules in the repository they govern are reviewable alongside the configuration, and a change to a rule appears in the same history. Rules held centrally are consistent across repositories and harder for one team to weaken. Most organisations want both: a small central set that cannot be overridden, plus repository-local rules teams own. Policy as code covers writing and testing them.
Test the policies themselves. A rule that never fires because its path expression is wrong is worse than no rule, because it produces a passing check that means nothing. Policy tests with fixture plans — one that should pass, one that should fail — are the only way to know.
Cost, honestly
Section titled “Cost, honestly”Cost estimation is a legitimate part of infrastructure review and the part most likely to produce false confidence.
What it does well: flagging the order of magnitude. A pull request that changes the monthly bill from tens to thousands is worth a comment, and an automated estimate catches that reliably.
What it does badly: anything usage-based. Storage, egress, request volume and anything auto-scaling are estimates built on assumptions the tool invented. A number presented to two decimal places implies a precision that is not there.
What to do with it: treat it as a signal for review attention, not as a figure anybody quotes. “This adds roughly an order of magnitude to the compute line” is useful. A specific monthly figure in a pull request comment will end up in a budget conversation, and it will be wrong.
This site does not publish cost figures for exactly that reason — they depend on region, commitment, negotiated rates and usage, and any number written here would be misleading somewhere.
Turning checks into gates
Section titled “Turning checks into gates”Validation that does not block is advice.
Required status checks in a ruleset make the checks unavoidable. Without this, everything above is optional.
Required reviews from code owners put the right human in front of the plan. CODEOWNERS for infrastructure covers the path patterns.
Distinguish must-pass from advisory. fmt, validate and plan success must pass. A cost estimate is information. Making everything blocking produces bypass habits.
Watch the bypass list. A ruleset with a long bypass list describes an intention rather than a control.
Concurrency and state locking
Section titled “Concurrency and state locking”A detail that only surfaces once more than one person is working in the repository, and then surfaces constantly.
Terraform locks state during any operation that reads or writes it — including plan, because planning refreshes state. Two pull requests planning the same root module at the same time contend for that lock.
Set -lock-timeout. Without it, the second run fails immediately with a lock error, which looks like a broken pipeline rather than a queue. Five minutes is a reasonable default; it converts a spurious failure into a short wait.
Group concurrency by root module, not by workflow. A concurrency key that includes the target directory lets pull requests touching different root modules plan in parallel while serialising those touching the same one.
Do not cancel in-progress plans by default. Cancelling a run mid-plan can leave a stale lock that somebody then has to clear manually. Queuing is slower and safer.
Never reach for -lock=false to get past a lock error. It is a documented flag and it is dangerous: it disables the mechanism preventing two operations from corrupting the same state. If a lock is genuinely stale — a runner was killed mid-plan — the correct action is terraform force-unlock with the specific lock ID, by a person who has confirmed nothing is actually running.
Making failures actionable
Section titled “Making failures actionable”A red check that does not say what to do produces a message in a chat channel rather than a fix.
Print the fix in the failure. terraform fmt -check -diff shows exactly what would change. A bare “formatting check failed” makes the developer reproduce it locally to find out.
Distinguish “your change is wrong” from “the pipeline broke”. A provider download timeout and a validation error are different problems and should not look the same. Where a step can fail for infrastructure reasons, say so in the message.
Name the root module in every failure. In a repository with thirty of them, “plan failed” is not a diagnosis.
Surface the first error, not the last hundred lines. Terraform errors are structured and the useful part is usually near the top; a log tail often shows only the exit status.
Run the cheap checks first, and let them fail fast. A developer waiting eight minutes for a plan to discover a formatting error learns to distrust the pipeline. The ladder at the top of this lesson is ordered for exactly this reason.
The general standard: a developer should be able to fix a failed check from the pull request page, without opening a log. Most of the work to reach that is in the failure messages rather than the checks themselves.
Common mistakes
Section titled “Common mistakes”Plan output only in CI logs. Reviewers approve the diff instead.
Appending a new comment per push. Nobody knows which is current.
Destroys buried in collapsed output. The most important line, hidden.
Posting full plans on public repositories. Attribute values are disclosure.
pull_request_target with a head checkout. Cloud credentials to arbitrary code.
Planning everything on every pull request. Slow, and trains reviewers to skim.
Path detection that ignores shared modules. Pull requests that appear to change nothing.
Same credentials for plan and apply. Plan needs read only.
Scanners with no baseline. Two hundred findings, then disabled.
Checks that are not required. Optional validation is advice.
What a good Terraform pull request looks like
Section titled “What a good Terraform pull request looks like”Pulling the whole lesson together: the state a reviewer should find when they open one.
A description stating intent in one sentence. “Add a read replica for the analytics database.” Not “infra changes”.
A link to the issue, incident or requirement. Six months later this is the only record of why.
The affected environments, named. And, if several, the order they will be applied in.
A plan comment with the counts first, destroys called out above the fold, full output collapsed underneath.
An explanation for every destroy. If the plan destroys something, the pull request says what and why, in the author’s words. This single convention prevents more incidents than any automated check, because it forces the author to look.
Passing checks that were required, not optional.
A code owner requested, automatically, because the paths are owned.
A recovery note for anything irreversible. “This drops the old table; a snapshot is taken by the pre-migration job and retained for 7 days” is the sentence you want to have written before you need it.
Most of that list is generated. The two items that are not — the intent sentence and the destroy explanation — are the ones that carry the most information, and they take the author about ninety seconds. A pull request template that asks for both is the cheapest quality improvement available in an infrastructure repository.
Mental model
Section titled “Mental model”Validation produces evidence. Surfacing decides whether the evidence is used. Rulesets decide whether it can be skipped.
All three are needed. A pipeline with excellent validation, buried output and optional checks has the cost of rigour and none of its benefit — which describes a surprising number of production Terraform setups.
What you learned
Section titled “What you learned”- Run static checks before the plan; only the plan needs credentials, and only read-only ones
validateis narrow: it will not tell you a change destroys somethingterraform show -jsonis the reliable way to extract counts and feed policy- Plan output contains resource attributes and is sensitive
- Never run Terraform against fork pull request code with real credentials
- Post the plan as an updated sticky comment, counts first, destroys visible
- Detect changed root modules, and handle shared modules explicitly or you will miss changes
- Required status checks are what make validation a gate rather than advice
Exercise
Section titled “Exercise”Use a disposable repository. No cloud credentials — local_file and null_resource suffice.
-
Add a workflow running
fmt -check,validateandplanon pull requests, posting the plan as a comment. -
Open a pull request that changes a
local_file’s content. Confirm the comment appears with counts. -
Push a second commit. Predict: does the comment update, or does a second appear? Fix it if it appends.
-
Change the file’s
filenameso the plan destroys and recreates. Predict: is the destroy visible without expanding anything? -
Add
terraform show -jsonand extract the destroy count withjq. Put it in the comment’s first line. -
Add a second root module. Add changed-directory detection so only the modified one plans. Then change a shared module. Predict: does your detection notice?
-
Make the plan job a required status check. Confirm merge is blocked while it fails.
-
Delete the repository.