Skip to content

Lab: Terraform Pull Request Workflow

Lesson 2 of 2Advanced5 min readHands-On Git & GitHub Labs · DevOps LabsVerified: Terraform 1.10.5, hashicorp/local provider 2.9.1
Time30 minutes
LevelAdvanced
You needTerraform 1.6+ locally (or Docker), and a GitHub repository to push to. No cloud account — the lab uses the local provider

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.

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.

A configuration using the local provider, so there is real state and a real plan with no cloud:

Terminal window
mkdir -p /tmp/lab-tf && cd /tmp/lab-tf
git 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.tfvars
printf '.terraform/\n*.tfstate*\nout/\n' > .gitignore
git add . && git commit -q -m "Initial Terraform configuration"
  1. Run the three checks locally, in the order the workflow will run them:

    Terminal window
    terraform init -input=false
    terraform fmt -check ; echo "fmt exit: $?"
    terraform validate
    terraform plan -input=false -var-file=dev.tfvars

    Read the plan. One resource to add.

  2. 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
  3. Make the “innocent” change on a branch, and misformat the file while you are at it:

    Terminal window
    git switch -c feature/scale-dev
    sed -i 's/default = 2/default = 3/' main.tf
    terraform fmt -check ; echo "fmt exit: $?"
    terraform fmt -diff

    Note the exit code — it is not 1.

  4. Fix formatting and plan the change:

    Terminal window
    terraform fmt
    git commit -qam "Scale dev to 3 replicas"
    terraform plan -input=false -var-file=dev.tfvars

    Read the plan as a reviewer. What is the action symbol? What does the last line say?

  5. Use the exit code CI will use:

    Terminal window
    terraform plan -input=false -var-file=dev.tfvars -detailed-exitcode ; echo "exit: $?"
    git switch main
    terraform plan -input=false -var-file=dev.tfvars -detailed-exitcode ; echo "exit: $?"
    git switch feature/scale-dev
  6. Try an invalid value to see validation fail before any plan:

    Terminal window
    terraform plan -input=false -var environment=production
  7. Write the workflow that runs steps 1 and 4 on every pull request and posts the plan:

    Terminal window
    mkdir -p .github/workflows
    cat > .github/workflows/terraform-plan.yml <<'EOF'
    name: Terraform plan
    on:
    pull_request:
    paths: ['**.tf', '**.tfvars', '.github/workflows/terraform-plan.yml']
    permissions:
    contents: read
    pull-requests: write # to post the plan as a comment
    concurrency:
    group: tf-plan-${{ github.ref }}
    cancel-in-progress: true
    jobs:
    plan:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
    - uses: actions/checkout@v7
    - uses: hashicorp/setup-terraform@v4
    with:
    terraform_version: "1.10.5"
    - run: terraform init -input=false
    - run: terraform fmt -check
    - run: terraform validate
    - name: Plan
    id: plan
    run: |
    set +e
    terraform plan -input=false -no-color -var-file=dev.tfvars -detailed-exitcode -out=tfplan > plan.txt
    echo "exitcode=$?" >> "$GITHUB_OUTPUT"
    - name: Post plan to the pull request
    uses: actions/github-script@v9
    env:
    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 error
    if: steps.plan.outputs.exitcode == '1'
    run: exit 1
    EOF
    git add . && git commit -q -m "Add Terraform plan workflow"
  8. Push and open the pull request. The plan appears as a comment. Read it as the reviewer who approved the “replicas” change last month.

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.

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.tf
fmt exit: 3
- default = 3
+ default = 3

Step 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: 2
exit: 0

Step 6:

Error: Invalid value for variable
on main.tf line 11:
11: variable "environment" {
environment must be dev, staging or prod.

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

  1. 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.

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.

Terminal window
cd /tmp && rm -rf lab-tf

Find a regression with git bisect — back to Git: let a test script find the commit that broke it.

Choose a learning pathA sequenced route through the curriculum for wherever you are now.