Skip to content
Infrastructure as Code iac cicd 4 min read

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.

StageCommandWhere it runsOutput
Planterraform plan -out=tfplanOn PR open/updateComment + saved artifact
Reviewhuman approvalPR reviewApproval
Applyterraform apply tfplanOn merge to mainApplied 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, the marocchino/sticky-pull-request-comment action, 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=tfplan and terraform 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=false and a -lock-timeout so jobs fail fast instead of hanging.
  • Parse terraform show -json to 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.
Last updated June 14, 2026
Was this helpful?