Scenario
Section titled “Scenario”Infrastructure changes go through pull requests, but the plan is run by whoever merges, after the review. Reviewers approve diffs of HCL, not diffs of infrastructure. Last month a change that looked like “bump replicas from 2 to 3” recreated a resource, and nobody saw it coming because nobody saw the plan.
Objective
Section titled “Objective”Build the pull request workflow — format check, validate, plan — using Terraform’s own exit codes, run every stage locally first, and then review a real plan that turns an innocent change into a destroy-and-create.
Prerequisites
Section titled “Prerequisites”- Terraform Git workflow — branching and state
- Terraform with GitHub Actions — the reference pipeline
- Terraform locally, or Docker (
docker run --rm -v "$PWD":/w -w /w hashicorp/terraform:1.10; alias that asterraformfor the commands below)
Starting state
Section titled “Starting state”A configuration using the local provider, so there is real state and a real plan with no cloud:
mkdir -p /tmp/lab-tf && cd /tmp/lab-tfgit init -q -b main .git config user.email "lab@example.com"git config user.name "Lab User"
cat > main.tf <<'EOF'terraform { required_version = ">= 1.6" required_providers { local = { source = "hashicorp/local" version = "~> 2.5" } }}
variable "environment" { type = string description = "Deployment environment name." validation { condition = contains(["dev", "staging", "prod"], var.environment) error_message = "environment must be dev, staging or prod." }}
variable "replicas" { type = number default = 2}
resource "local_file" "config" { filename = "${path.module}/out/${var.environment}.json" content = jsonencode({ environment = var.environment replicas = var.replicas })}
output "config_path" { value = local_file.config.filename}EOF
printf 'environment = "dev"\n' > dev.tfvarsprintf '.terraform/\n*.tfstate*\nout/\n' > .gitignoregit add . && git commit -q -m "Initial Terraform configuration"-
Run the three checks locally, in the order the workflow will run them:
Terminal window terraform init -input=falseterraform fmt -check ; echo "fmt exit: $?"terraform validateterraform plan -input=false -var-file=dev.tfvarsRead the plan. One resource to add.
-
Apply once so there is state to plan against — this stands in for the deployed baseline:
Terminal window terraform apply -input=false -auto-approve -var-file=dev.tfvars -
Make the “innocent” change on a branch, and misformat the file while you are at it:
Terminal window git switch -c feature/scale-devsed -i 's/default = 2/default = 3/' main.tfterraform fmt -check ; echo "fmt exit: $?"terraform fmt -diffNote the exit code — it is not 1.
-
Fix formatting and plan the change:
Terminal window terraform fmtgit commit -qam "Scale dev to 3 replicas"terraform plan -input=false -var-file=dev.tfvarsRead the plan as a reviewer. What is the action symbol? What does the last line say?
-
Use the exit code CI will use:
Terminal window terraform plan -input=false -var-file=dev.tfvars -detailed-exitcode ; echo "exit: $?"git switch mainterraform plan -input=false -var-file=dev.tfvars -detailed-exitcode ; echo "exit: $?"git switch feature/scale-dev -
Try an invalid value to see validation fail before any plan:
Terminal window terraform plan -input=false -var environment=production -
Write the workflow that runs steps 1 and 4 on every pull request and posts the plan:
Terminal window mkdir -p .github/workflowscat > .github/workflows/terraform-plan.yml <<'EOF'name: Terraform planon:pull_request:paths: ['**.tf', '**.tfvars', '.github/workflows/terraform-plan.yml']permissions:contents: readpull-requests: write # to post the plan as a commentconcurrency:group: tf-plan-${{ github.ref }}cancel-in-progress: truejobs:plan:runs-on: ubuntu-latesttimeout-minutes: 15steps:- uses: actions/checkout@v7- uses: hashicorp/setup-terraform@v4with:terraform_version: "1.10.5"- run: terraform init -input=false- run: terraform fmt -check- run: terraform validate- name: Planid: planrun: |set +eterraform plan -input=false -no-color -var-file=dev.tfvars -detailed-exitcode -out=tfplan > plan.txtecho "exitcode=$?" >> "$GITHUB_OUTPUT"- name: Post plan to the pull requestuses: actions/github-script@v9env:PLAN: ${{ steps.plan.outputs.exitcode }}with:script: |const fs = require('fs');const plan = fs.readFileSync('plan.txt', 'utf8').slice(0, 60000);const status = { '0': 'No changes', '2': 'Changes present', '1': 'Plan failed' }[process.env.PLAN] ?? 'Unknown';await github.rest.issues.createComment({owner: context.repo.owner,repo: context.repo.repo,issue_number: context.issue.number,body: `### Terraform plan — ${status}\n\n<details><summary>Show plan</summary>\n\n\`\`\`\n${plan}\n\`\`\`\n\n</details>`,});- name: Fail on plan errorif: steps.plan.outputs.exitcode == '1'run: exit 1EOFgit add . && git commit -q -m "Add Terraform plan workflow" -
Push and open the pull request. The plan appears as a comment. Read it as the reviewer who approved the “replicas” change last month.
Validation
Section titled “Validation”Step 3’s fmt -check exits 3. Step 4’s plan says must be replaced with -/+, and ends
Plan: 1 to add, 0 to change, 1 to destroy. Step 5 exits 2 on the branch and 0 on main. Step 6
fails with the validation message before planning. On GitHub the plan comment shows the same
-/+.
Step 3. terraform fmt -check exits 3 when files need formatting, 0 when clean, and 1 for
errors. A CI step treats any non-zero as failure, so it works — but if you are scripting around
it, 3 is not “error”.
Step 4. local_file cannot change its content in place; a new content means a new file, so
Terraform destroys and recreates it. The plan says so in two places: the -/+ prefix and the
# forces replacement annotation. Real providers do the same for immutable attributes — an
instance type, a database engine version, a subnet.
Step 5. -detailed-exitcode returns 0 for no changes, 1 for error, 2 for changes. It lets a
workflow distinguish “nothing to do” from “review needed” without parsing text.
Solution
Section titled “Solution”Step 1 — the initial plan, abbreviated:
Terraform will perform the following actions: # local_file.config will be created + resource "local_file" "config" { + content = jsonencode( { + environment = "dev" + replicas = 2 } ) + filename = "./out/dev.json" + id = (known after apply) }Step 3:
main.tffmt exit: 3- default = 3+ default = 3Step 4 — the plan the reviewer needs to see:
Terraform will perform the following actions: # local_file.config must be replaced-/+ resource "local_file" "config" { ~ content = jsonencode( ~ { ~ replicas = 2 -> 3 # (1 unchanged attribute hidden) } # forces replacement ) ~ id = "a46356b662e45480988208cd16892d2469b3126a" -> (known after apply) # (3 unchanged attributes hidden) }Plan: 1 to add, 0 to change, 1 to destroy.Step 5:
exit: 2exit: 0Step 6:
Error: Invalid value for variable
on main.tf line 11: 11: variable "environment" {
environment must be dev, staging or prod.Explanation
Section titled “Explanation”Review the plan, not the HCL. The HCL diff for this change is one character. The plan says a resource will be destroyed. Those are different facts, and only the second one matters to the person approving. Putting the plan on the pull request is what makes infrastructure review real.
-/+ is the symbol to fear. ~ is update-in-place; -/+ is destroy-then-create. For a
config file it is harmless. For a database, a load balancer or a subnet it is an outage, and the
plan tells you in advance — if someone reads it.
Exit codes are the interface. fmt -check → 3, plan -detailed-exitcode → 2, validate →
- Each is machine-readable, which is why the workflow does not grep plan output to decide what happened.
Plan on pull request, apply on merge. The plan job needs read-only access to state. The apply
job — not in this lab — runs on push to main, ideally in an environment with required
reviewers, and should apply the saved plan (tfplan) rather than re-planning, so what was
reviewed is what runs.
Troubleshooting
Section titled “Troubleshooting”terraform init fails downloading the provider. No network in the sandbox, or a proxy.
The local provider is tiny; check connectivity to registry.terraform.io.
Step 4 shows ~ update in-place rather than -/+. Provider version difference; older
local provider versions behaved the same, but verify with terraform providers.
The comment step fails with 403. pull-requests: write is missing, or the pull request is
from a fork — fork pull requests get a read-only token regardless. Use the
workflow_run split for fork contributions.
Clean up
Section titled “Clean up”cd /tmp && rm -rf lab-tfRelated lessons
Section titled “Related lessons”Next lab
Section titled “Next lab”Find a regression with git bisect — back to Git: let a test script find the commit that broke it.