Plan on Pull Requests
The plan-on-PR pattern turns terraform plan into a first-class part of code review. Instead of trusting that a change “looks safe,” your pipeline generates a plan for every pull request, posts the human-readable diff as a comment, and stores the binary plan as an artifact. On merge, the pipeline applies exactly that saved plan rather than re-planning, eliminating the gap between what was reviewed and what was executed. This is the single most important guardrail for safe Terraform collaboration, and it works identically with OpenTofu.
Why plan-on-PR matters
A reviewer approving a .tf diff is approving intent. But Terraform’s actual behavior depends on current remote state, provider versions, data sources, and count/for_each evaluation — none of which are visible in the HCL diff alone. A plan resolves all of that into a concrete list of creates, updates, and destroys.
Posting that plan on the PR gives reviewers the real blast radius. Saving the plan and applying it verbatim guarantees no drift slips in between approval and execution.
| Stage | Command | Where it runs | Output |
|---|---|---|---|
| Plan | terraform plan -out=tfplan | On PR open/update | Comment + saved artifact |
| Review | human approval | PR review | Approval |
| Apply | terraform apply tfplan | On merge to main | Applied changes |
Generating a saved plan
Always plan to a file with -out. A saved plan file is the contract between review and apply. Use -input=false and -lock-timeout so the job never hangs waiting for a terminal or a stuck state lock.
terraform init -input=false
terraform plan -input=false -lock-timeout=120s -out=tfplan
To produce a machine-readable version for parsing or policy checks, convert the saved plan to JSON. This is what you feed into Conftest/OPA or use to detect destroys.
terraform show -json tfplan > tfplan.json
The HCL example below is the kind of change a PR might introduce — a real resource, not a placeholder.
resource "aws_s3_bucket" "logs" {
bucket = "devcraftly-app-logs"
}
resource "aws_s3_bucket_versioning" "logs" {
bucket = aws_s3_bucket.logs.id
versioning_configuration {
status = "Enabled"
}
}
A typical plan for that change looks like this.
Output:
Terraform will perform the following actions:
# aws_s3_bucket.logs will be created
+ resource "aws_s3_bucket" "logs" {
+ bucket = "devcraftly-app-logs"
+ id = (known after apply)
+ arn = (known after apply)
}
# aws_s3_bucket_versioning.logs will be created
+ resource "aws_s3_bucket_versioning" "logs" {
+ bucket = (known after apply)
+ versioning_configuration {
+ status = "Enabled"
}
}
Plan: 2 to add, 0 to change, 0 to destroy.
Saved the plan to: tfplan
Posting the plan as a PR comment
The plan text only helps reviewers if it lands on the PR. Capture the human-readable plan (not the binary file) and post it as a comment. With GitHub Actions you can use the gh CLI directly.
terraform show -no-color tfplan > plan.txt
# Trim very large plans so the comment stays under GitHub's 65k char limit
head -c 60000 plan.txt > plan.trimmed.txt
{
echo '### Terraform Plan'
echo '```text'
cat plan.trimmed.txt
echo '```'
} > comment.md
gh pr comment "$PR_NUMBER" --body-file comment.md
Tip: Re-running the plan on each push creates a noisy thread of comments. Prefer a “sticky” comment that updates in place —
gh pr comment --edit-last, themarocchino/sticky-pull-request-commentaction, or a GitLab note posted via the API — so reviewers always see the latest plan.
Saving and reusing the exact plan
The binary tfplan file is the artifact that makes apply-on-merge safe. Persist it from the PR run and load it back in the merge run. Because the plan is keyed to a specific state serial, applying a stale plan will fail loudly rather than silently doing the wrong thing.
# .github/workflows/terraform.yml (excerpt)
name: terraform
on:
pull_request:
push:
branches: [main]
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init -input=false
- run: terraform plan -input=false -out=tfplan
- uses: actions/upload-artifact@v4
with:
name: tfplan
path: tfplan
retention-days: 7
On merge, download that artifact and apply it without a fresh plan.
terraform apply -input=false tfplan
If state has changed since the plan was created, Terraform refuses to apply.
Output:
╷
│ Error: Saved plan is stale
│
│ The given plan file can no longer be applied because the state was
│ changed by another operation after the plan was created.
╵
That error is a feature: it forces a fresh, re-reviewed plan instead of applying outdated intent.
Guarding against destroys
Use the JSON plan to fail the PR check (or require an extra approval) when resources will be destroyed. This catches accidental for_each key changes and renamed resources before merge.
destroys=$(terraform show -json tfplan \
| jq '[.resource_changes[]
| select(.change.actions | index("delete"))] | length')
if [ "$destroys" -gt 0 ]; then
echo "Plan would destroy $destroys resource(s); requires approval."
exit 1
fi
Best practices
- Always
terraform plan -out=tfplanandterraform apply tfplan— never re-plan at apply time. - Post the plan as an updating sticky comment so reviewers see the real diff, not just HCL.
- Store the binary plan as a short-retention artifact and apply that exact file on merge.
- Run plans with
-input=falseand a-lock-timeoutso jobs fail fast instead of hanging. - Parse
terraform show -jsonto block or gate plans that destroy resources. - Scope credentials for the PR plan job to read-only where possible; reserve write access for the merge apply job.
- Treat a “stale plan” error as a signal to re-review, never as something to force past.