A Terraform pull request is not a code review.
The diff shows what the configuration will say. The plan shows what will happen to real infrastructure. Those are different objects, and a reviewer who reads only the first is approving something they have not seen — because a three-character change to a variable can produce a plan that destroys a database, and nothing in the diff communicates that.
Everything in this lesson exists to close that gap before somebody clicks merge.
The workflow
Section titled “The workflow”A vertical sequence: a Terraform change; a short-lived feature branch; a pull request; terraform fmt; terraform validate; terraform plan; human review of both diff and plan; merge; and a controlled apply after merge.
Read the order carefully. plan runs before the merge; apply runs after it. That ordering is the entire safety property of the workflow, and the rest of this lesson is mostly about defending it.
The short answer
Section titled “The short answer”Branch, change, open a pull request. CI runs fmt -check, validate and plan, and posts the plan where a reviewer can read it. A human who understands the affected system approves both the code and its effect. Merge. A separate, environment-protected job runs apply — from the merged commit, not from the pull request.
Everything else is detail about how to make each of those steps trustworthy.
Why apply must not run on pull request code
Section titled “Why apply must not run on pull request code”The most important paragraph in this cluster.
A pull request contains code somebody proposed. On a public repository that is anybody; on a private one it is anybody with write access, which is usually a much wider group than the people you would trust to change production. terraform apply executes provider operations against real infrastructure using real credentials.
Connecting those two directly means arbitrary proposed code runs with your cloud credentials. The attack does not even need to be subtle — a local-exec provisioner, an external data source, or simply a resource change that does something expensive.
The safe boundary:
| Stage | Runs on | Credentials |
|---|---|---|
fmt, validate | Pull request code | None |
plan | Pull request code | Read-only, scoped |
apply | The merged commit | Write, environment-protected, approved |
Two consequences people miss:
Plan needs credentials too. Terraform refreshes state against the provider to compute a plan, which requires reading real resources. Those credentials should be read-only and scoped — a plan role, not the apply role.
Plan output is sensitive. It contains resource attributes, and attributes contain things like connection strings. A plan posted publicly on an open-source repository’s pull request is a disclosure. Pull request validation covers handling that properly.
Branch, and keep it short
Section titled “Branch, and keep it short”Infrastructure branches should be shorter-lived than application branches, for a reason specific to this domain.
An application branch that sits for two weeks accumulates merge conflicts. A Terraform branch that sits for two weeks accumulates something worse: its plan goes stale. The plan was computed against the state and the real world at a moment. Two weeks later, other applies have happened, resources have changed outside Terraform, and the reviewed plan describes a situation that no longer exists.
Practical consequences:
One concern per branch. A branch that adds a queue and refactors the networking module produces a plan nobody can review as a unit.
Rebase or re-plan before merging. A plan produced before three other merges is evidence about a different world. Most CI setups re-run the plan on push; make sure yours re-runs it after a base update too.
Do not use long-lived environment branches. This is common enough to have its own lesson — branching strategy — but the short version is that production as a permanent branch turns every promotion into a merge with conflict resolution, and conflict resolution in infrastructure configuration is how environments silently diverge.
Trunk-based, with short-lived feature branches, is the default that causes the fewest problems.
Format and validate
Section titled “Format and validate”The cheap steps, run first because they cost nothing.
terraform fmt -check -recursive-check reports files that need formatting and exits non-zero without changing anything. That is the correct behaviour for CI: the pipeline should fail, not fix. A pipeline that commits formatting changes back is a pipeline that pushes to branches, which is a permission you should not grant to save a developer one command.
terraform init -backend=falseterraform validatevalidate checks that the configuration is internally consistent — syntax, argument names, type constraints, references that resolve. It does not contact providers and does not look at state.
The -backend=false on init matters: validation does not need the backend, and skipping it means this step needs no state credentials at all. Fast, and one fewer place credentials appear.
The plan
Section titled “The plan”The step the whole workflow is built around.
terraform initterraform plan -input=false -lock-timeout=5m -out=tfplanThree flags doing specific work:
-input=false stops Terraform prompting for missing variables. In CI a prompt is a hang, not a question.
-lock-timeout=5m waits rather than failing immediately when another operation holds the state lock. Without it, two concurrent pull requests produce a spurious failure.
-out=tfplan writes the plan to a file. This matters for apply, discussed below.
For pipelines that need to branch on whether anything changed:
terraform plan -input=false -detailed-exitcode -out=tfplan-detailed-exitcode returns 0 for no changes, 1 for an error, and 2 for changes present. Note that this inverts the usual shell convention — a non-zero exit is the normal case when there is work to do — so any set -e script needs to handle it deliberately:
set +eterraform plan -input=false -detailed-exitcode -out=tfplancode=$?set -ecase "$code" in 0) echo "No changes." ;; 2) echo "Changes present." ;; *) echo "Plan failed." >&2; exit "$code" ;;esacReading a plan as a reviewer
Section titled “Reading a plan as a reviewer”The four verbs, in ascending order of how carefully you should look:
| Symbol | Meaning | Reviewer attention |
|---|---|---|
+ | Create | Normal |
~ | Update in place | Normal |
-/+ | Destroy and recreate | High — the resource ceases to exist for a period |
- | Destroy | Highest — and if unintended, stop |
The -/+ case is where reviewers are most often surprised. It happens because a changed attribute is marked as forcing replacement by the provider — and the diff that caused it can be a single character. Terraform states the reason inline (# forces replacement), and that annotation is the single most important thing in a plan.
The number to read first is the destroy count. Plan: 2 to add, 1 to change, 0 to destroy is routine. Any non-zero destroy count on a production plan deserves a sentence in the pull request explaining it.
Review
Section titled “Review”What a reviewer is actually being asked to certify.
That the code is correct. The ordinary review.
That the plan matches the intent. The pull request says “add a read replica”; the plan should add a read replica and not much else. A plan containing surprises means either the code does something the author did not realise, or the state has drifted.
That destructive operations are intended. Every - and -/+, named and justified.
That the security impact is acceptable. Networking, IAM, public exposure, encryption settings. This is where CODEOWNERS earns its place: the reviewer needs to be somebody who would recognise a bad network rule.
That there is a recovery path. Not a formality. “If this is wrong, we revert the commit and re-apply” is only true for resources that can be recreated without loss.
Merge and apply
Section titled “Merge and apply”After merge, a separate job applies. Three properties make it safe.
It runs from the merged commit. Not the pull request branch. What was reviewed and what is applied should be the same tree.
It is environment-protected. GitHub environments with required reviewers mean production apply waits for a named person, and the approval is recorded. This is where the human decision to change production actually happens.
It is serialised. Two concurrent applies against the same state either collide on the lock or interleave badly. Workflow concurrency groups prevent the second run from starting until the first finishes.
Saved plan or re-plan?
Section titled “Saved plan or re-plan?”A genuine design choice with real trade-offs.
Applying a saved plan file (terraform apply tfplan) guarantees that what was reviewed is what is applied. Terraform will refuse the plan if the state has moved on, which is a feature: it tells you the world changed. The costs are that the plan file is an artifact you must store securely — it contains the same sensitive attribute values the plan output does — and that a stale plan means re-running rather than applying.
Re-planning at apply time is simpler and always current. The cost is that the applied change is not literally the reviewed one; usually identical, occasionally not.
Most teams re-plan for low-risk environments and apply saved plans for production. Either is defensible; what is not defensible is applying a saved plan file that is stored somewhere readable, or re-planning in production without anybody looking at the new plan.
Credentials
Section titled “Credentials”The workflow above needs credentials three times, and they should not be the same credentials.
Validate needs none. With -backend=false, nothing is contacted.
Plan needs read access to the provider and to the state backend. A dedicated plan role with read-only provider permissions is the correct scope. It cannot be zero — Terraform refreshes real resources to compute a plan.
Apply needs write access, and only in the job that runs after merge, gated behind an environment.
The mechanism that makes this practical is OIDC: the workflow exchanges a short-lived, workload-scoped token with the cloud provider instead of holding a long-lived key. Two properties matter here specifically.
No standing credential exists to leak. There is no access key in repository secrets that a compromised workflow could exfiltrate.
The trust policy can distinguish the jobs. A cloud role’s trust condition can require a specific repository, a specific branch and a specific environment — so the apply role is assumable only from the environment-protected job on the default branch, and the plan role only from pull request runs. That turns the plan/apply separation from a convention in a YAML file into something the cloud provider enforces.
Removing long-lived cloud credentials covers the migration.
Large repositories: planning only what changed
Section titled “Large repositories: planning only what changed”A repository with thirty root modules cannot plan all of them on every pull request. It is slow, it produces unreadable output, and it makes reviewers scroll past thirty “no changes” blocks looking for the one that matters.
Detect the changed directories and plan those. git diff --name-only origin/main...HEAD gives the changed paths; map them to the root modules containing them.
Account for shared modules. A change to modules/networking/ affects every root module consuming it. Path-based detection alone will miss that, and the resulting pull request looks like it changes nothing while actually changing everything downstream. Either maintain an explicit dependency map, or plan everything when a shared module changes — the second is cruder and much harder to get wrong.
Use a matrix. Each root module plans as its own job, so failures are attributable and plans are separate.
Keep the aggregate summary short. Which modules have changes, and how many destroys in each. Reviewers read a summary; they open the detail for the modules that summary flags.
Drift, and what the workflow cannot see
Section titled “Drift, and what the workflow cannot see”The workflow assumes the only changes to infrastructure come through it. That assumption fails.
Somebody adjusts a security group in the console during an incident. An autoscaler changes a capacity. Another team’s tooling modifies a shared resource. None of these produce a commit, and Terraform does not know about them until the next plan refreshes state.
The visible symptom is a plan containing changes nobody in the pull request wrote. That is disorienting during a review, and the correct response is to stop and find out what happened rather than approving a plan you did not intend.
Two habits substantially reduce the surprise:
Scheduled plans. A nightly terraform plan on the default branch that reports whether anything has drifted. It catches out-of-band changes when there is time to think about them, not while somebody is waiting on a review.
A refresh-only plan (terraform plan -refresh-only) shows how state differs from reality without proposing configuration changes. It is the right tool for answering “has anything moved?” without conflating that question with “what does this pull request do?”
Infrastructure drift detection covers the operational model, including the decision about whether to correct drift automatically or investigate it.
Policy in the workflow
Section titled “Policy in the workflow”Some things should not be approvable, however good the reviewer.
Static analysis on the configuration catches known-bad patterns — a wide-open ingress rule, an unencrypted volume, a public storage bucket — before anybody reads the plan.
Policy against the plan is stronger, because it evaluates what will actually happen. terraform show -json tfplan produces a machine-readable plan that a policy engine can assess: “no plan may destroy a resource tagged protected”, “no plan may open port 22 to the internet”.
Rulesets enforce the process itself: required checks, required reviewers, no direct pushes to the default branch. Without those, the entire workflow is optional.
The layering matters. Static analysis and plan policy are advisory-to-blocking checks inside the pipeline. Rulesets are what makes the pipeline unavoidable. Policy as code covers writing and testing the rules themselves.
Destructive changes deserve friction
Section titled “Destructive changes deserve friction”Not every change carries equal risk, and treating them identically means either over-controlling routine work or under-controlling dangerous work.
A plan with zero destroys is the routine case. Normal review.
A plan destroying stateless resources — an instance in an autoscaling group, a load balancer target — is recoverable. Normal review, with the destroys called out.
A plan destroying stateful resources — a database, a volume, a bucket with data — is a different category. The pull request should say what happens to the data, and the reviewer should be somebody who would know.
The mechanism worth adding early is Terraform’s own: prevent_destroy in a resource’s lifecycle block causes a plan that would destroy it to fail outright.
resource "aws_db_instance" "primary" { # ... lifecycle { prevent_destroy = true }}This is a guard rail rather than a wall — removing the block is itself a configuration change, which is exactly right, because it means destroying that database requires a deliberate, reviewable commit that says so.
Common mistakes
Section titled “Common mistakes”Running apply on pull requests. Arbitrary proposed code with your cloud credentials.
Reviewing the diff without the plan. The diff does not contain the consequence.
Ignoring the destroy count. The number that predicts incidents.
Long-lived environment branches. Merges between them diverge environments silently.
Formatting fixes pushed by CI. Grants your pipeline write access to branches to save one command.
No state locking or lock timeout. Concurrent runs corrupt state or fail spuriously.
Plan output posted publicly. It contains resource attributes.
Applying a stale plan. Reviewed against a world that has moved.
Same credentials for plan and apply. Plan needs read; apply needs write. Separate them.
Getting the plan in front of people
Section titled “Getting the plan in front of people”A recurring operational failure: the workflow does everything right and nobody reads the plan, because reading it costs four clicks into a CI log.
Post it as a pull request comment. The plan should be visible where the review happens. This is the single highest-return improvement to most Terraform workflows.
Update one comment rather than appending. A pull request with six plan comments from six pushes is one where the reader has to work out which is current. Sticky comments — edit the existing one — keep it unambiguous.
Lead with the summary line. Plan: 2 to add, 1 to change, 0 to destroy at the top, with the full plan in a collapsed block underneath. Reviewers triage on the counts and expand when the counts warrant it.
Flag destroys explicitly. A comment that says ”⚠ 1 resource will be destroyed: aws_db_instance.primary” gets read. A destroy buried on line 340 of a collapsed block does not.
Use -no-color. Terraform’s ANSI escape codes render as noise in a Markdown comment.
Truncate, and say so. Very large plans exceed comment size limits. Truncating with an explicit “output truncated, full plan in the run log” is honest; silently cutting it off is not.
The reason to invest here is that the whole workflow’s value depends on this one step. Everything upstream produces evidence; if the evidence is inconvenient to read, the process degrades into diff-only review while still looking rigorous on paper.
Mental model
Section titled “Mental model”A Terraform pull request asks a reviewer to approve two things: a diff, and a consequence. Only one of them is visible by default.
The whole workflow is machinery for making the second one visible in time to matter. Format and validate catch cheap errors. The plan renders the consequence. Review is where a human decides it is acceptable. Merge records the decision. Apply executes it, separately, under different credentials, after the decision was made.
Remove the plan from that chain and you have an application workflow being used on infrastructure — which works right up until the day it does not.
What you learned
Section titled “What you learned”planruns before merge on pull request code;applyruns after merge on the merged commit- Applying pull request code means running arbitrary proposals with your cloud credentials
- Plan needs read-only credentials of its own, and its output contains sensitive attributes
-detailed-exitcodereturns 0 for no changes, 1 for error, 2 for changes present-/+and-are the lines that predict incidents; the destroy count is the number to read first- Infrastructure branches should be short-lived because plans go stale, not because of conflicts
- Saved plans guarantee what was reviewed is applied, at the cost of storing a sensitive artifact
Exercise
Section titled “Exercise”Use a disposable repository and a throwaway cloud project or the local / null providers. No production credentials.
-
Create a repository with a small Terraform configuration using only the
null_resourceandlocal_fileresources — no cloud provider needed. -
Add a workflow running
fmt -check,validateandplanon pull requests. Confirm all three run and that the plan appears where you can read it. -
Open a pull request that changes a
local_filecontent. Read the plan. Predict: update in place, or destroy and recreate? -
Open a pull request that changes the file’s filename. Predict: what does the plan show now, and why is it different?
-
Add
-detailed-exitcodeand make the workflow print which of the three outcomes occurred. Open a pull request with no functional change. Predict: which exit code? -
Try to add an
applystep that runs onpull_request. Before running it, write down what a malicious pull request could do with it. Then delete the step. -
Delete the repository.