Terraform Pipelines with GitHub Actions¶
Overview¶
Running terraform apply from a laptop does not scale. GitHub Actions should run init → validate → plan on every pull request, upload the plan artefact, and only apply on protected branches after human approval — using remote state with locking and short-lived cloud roles (OpenID Connect (OIDC)) instead of long-lived access keys. Labs must also practise destroy discipline so sandbox resources do not leak cost.
This is Tutorial 9 in Module 9: Terraform Pipelines of the REBASH Academy GitHub Actions for Cloud & DevOps Engineers series — written for Cloud, DevOps, Platform, and Site Reliability Engineering (SRE) engineers.
Prerequisites¶
- Kubernetes Deployments with GitHub Actions
- Terraform workflow — init, plan, apply
- Terraform CLI 1.5+ (or Docker image
hashicorp/terraform) - Optional cloud account — the lab uses a local/null backend mock
Learning Objectives¶
By the end of this tutorial, you will be able to:
- Structure a GitHub Actions workflow for Terraform init/validate/plan/apply
- Explain remote state and locking in CI
- Gate production apply behind environment protection — never auto-apply without approval
- Upload and consume plan files as workflow artefacts
- Document destroy rules for lab environments
Architecture¶
Pull requests plan only; apply on main consumes the saved plan after environment approval.
Theory¶
What it is¶
| Stage | Command intent |
|---|---|
| Init | terraform init — backends and providers |
| Validate | terraform validate |
| Plan | terraform plan -out=tfplan |
| Apply | terraform apply tfplan |
| Destroy | terraform destroy (labs / teardown workflows) |
Remote state (Amazon Simple Storage Service (S3) + DynamoDB, Azure Storage, Google Cloud Storage with locking) is the production default so every runner shares one state file. The lab uses local state with clear warnings.
Credentials: inject cloud roles via OIDC federation (Modules 5 and 10) where possible. Static access keys in repository secrets work but need rotation and least privilege.
Approvals: GitHub Environments with required reviewers, or a separate workflow_dispatch apply job, or GitOps-style promotion. Never auto-apply random pull request plans to production.
Why it matters¶
Unreviewed apply from a feature branch can delete databases. Plan artefacts prove what was approved. State locking prevents two workflow runs from corrupting state. Destroy workflows prevent abandoned lab Virtual Private Clouds (VPCs) from billing indefinitely.
How it works¶
- Checkout the Terraform root module.
initwith backend config (often injected via-backend-configor environment variables).validate+plan -out=tfplan.actions/upload-artifactthe binarytfplan(and human-readableplan.txtfromterraform show).- On
main(or after environment approval):terraform apply -auto-approve tfplanusing the same plan file. - Tear down labs with a dedicated destroy workflow and guardrails.
Key concepts and comparisons¶
| Anti-pattern | Better |
|---|---|
apply without plan file | Apply saved tfplan |
| Auto-apply all branches | if: github.ref == 'refs/heads/main' + environment |
| State on runner disk only | Remote backend + lock |
| Admin cloud keys on pull request CI | Narrow roles; no apply on pull requests |
Common pitfalls¶
- Applying a stale plan after newer commits merged.
- Different backend config between plan and apply jobs.
- Printing secret variable values in plan logs.
- No locking → concurrent applies corrupt state.
- Forgetting destroy for ephemeral labs.
Hands-on Lab¶
Objective¶
Create a Docker-backed Terraform module, run init/plan/apply/destroy locally, author GitHub Actions workflows with plan artefact upload and gated apply, and enforce destroy discipline with a shell script.
Prerequisites¶
- Docker Engine running (
docker info) - Terraform CLI 1.5+ (
terraform version) - Python 3 with PyYAML for offline YAML validation
- Optional: test GitHub repository to push workflows
Lab environment¶
Workspace: ~/rebash-github-actions/module-09
mkdir -p ~/rebash-github-actions/module-09/{.github/workflows,tf-demo} && cd ~/rebash-github-actions/module-09
set -euo pipefail
docker info | tee docker-info.txt
terraform version | tee terraform-version.txt
Real-world scenario¶
Platform requires every infrastructure change to show a stored plan before apply. Production apply is manual-approved on main only through a protected production environment. Labs must destroy resources within 24 hours.
Step-by-step tasks¶
Task 1 – Docker-backed Terraform module¶
Create tf-demo/main.tf:
terraform {
required_version = ">= 1.5.0"
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 3.0"
}
}
}
provider "docker" {}
resource "docker_image" "nginx" {
name = "nginx:1.25-alpine"
keep_locally = false
}
resource "docker_container" "rebash" {
name = "rebash-gha-tf-lab"
image = docker_image.nginx.image_id
ports {
internal = 80
external = 8080
}
}
output "container_id" {
value = docker_container.rebash.id
}
output "url" {
value = "http://127.0.0.1:8080"
}
Validate and plan locally:
cd ~/rebash-github-actions/module-09/tf-demo
set -euo pipefail
docker info >/dev/null
terraform init | tee ../init.txt
terraform validate | tee ../validate.txt
terraform plan -out=tfplan -input=false | tee ../plan.txt
terraform show -no-color tfplan > ../plan-show.txt
test -f tfplan
grep -q 'docker_container.rebash' ../plan.txt
cd ..
Expected output
plan.txt shows docker_container.rebash will be created; tfplan exists.
Task 2 – Plan workflow (pull requests plan only)¶
Create .github/workflows/terraform-plan.yml:
name: Terraform Plan
on:
pull_request:
paths:
- 'tf-demo/**'
workflow_dispatch:
permissions:
contents: read
pull-requests: write
jobs:
plan:
runs-on: ubuntu-latest
defaults:
run:
working-directory: tf-demo
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.5.7
- name: Terraform Init
run: terraform init -input=false
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
run: |
terraform plan -input=false -out=tfplan
terraform show -no-color tfplan > plan.txt
- name: Upload plan artefact
uses: actions/upload-artifact@v4
with:
name: tfplan-${{ github.event.pull_request.number || github.run_id }}
path: |
tf-demo/tfplan
tf-demo/plan.txt
retention-days: 7
Validate offline:
cd ~/rebash-github-actions/module-09
set -euo pipefail
python3 -c "import yaml; yaml.safe_load(open('.github/workflows/terraform-plan.yml')); print('plan workflow OK')"
grep -q 'upload-artifact' .github/workflows/terraform-plan.yml
Expected output
plan workflow OK; artefact upload step present.
Task 3 – Gated apply workflow (main + environment)¶
Create .github/workflows/terraform-apply.yml:
name: Terraform Apply
on:
workflow_dispatch:
inputs:
plan_run_id:
description: 'Run ID of plan workflow that produced tfplan'
required: true
type: string
permissions:
contents: read
jobs:
apply:
runs-on: ubuntu-latest
environment: production
defaults:
run:
working-directory: tf-demo
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.5.7
- name: Download plan artefact
uses: actions/download-artifact@v4
with:
name: tfplan-${{ inputs.plan_run_id }}
path: tf-demo
- name: Terraform Init
run: terraform init -input=false
- name: Terraform Apply saved plan
run: terraform apply -input=false -auto-approve tfplan
Validate offline:
cd ~/rebash-github-actions/module-09
set -euo pipefail
python3 -c "import yaml; yaml.safe_load(open('.github/workflows/terraform-apply.yml')); print('apply workflow OK')"
grep -q 'environment: production' .github/workflows/terraform-apply.yml
grep -q 'terraform apply' .github/workflows/terraform-apply.yml
Expected output
apply workflow OK; environment protection hook present.
Task 4 – Apply, prove, destroy, and record policy¶
Create destroy-checks.sh:
#!/usr/bin/env bash
set -euo pipefail
LAB_TTL_HOURS=24
echo "lab_ttl_hours=${LAB_TTL_HOURS}" | tee destroy-policy.txt
echo "backend=local-docker-provider" >> destroy-policy.txt
echo "production_backend=S3/GCS/Azure-with-locking" >> destroy-policy.txt
echo "pull_request=plan-only-no-apply-credentials" >> destroy-policy.txt
echo "production_destroy=separate-workflow-dual-approval" >> destroy-policy.txt
grep -q 'plan-only' destroy-policy.txt
docker info >/dev/null
cd tf-demo
terraform init -input=false
terraform validate
terraform plan -input=false -out=tfplan
terraform apply -input=false -auto-approve tfplan
docker ps --filter name=rebash-gha-tf-lab --format '{{.Names}} {{.Status}}' | tee ../container-proof.txt
grep -q 'rebash-gha-tf-lab' ../container-proof.txt
curl -sf http://127.0.0.1:8080 >/dev/null
terraform destroy -input=false -auto-approve
cd ..
echo "destroy_attempted=yes" >> destroy-policy.txt
echo 'destroy-checks passed'
Run and archive:
cd ~/rebash-github-actions/module-09
set -euo pipefail
chmod +x destroy-checks.sh
./destroy-checks.sh | tee destroy-checks-output.txt
tar -czf module-09-evidence.tgz tf-demo/main.tf .github/workflows/*.yml destroy-policy.txt container-proof.txt *.txt tf-demo/tfplan 2>/dev/null || \
tar -czf module-09-evidence.tgz tf-demo/main.tf .github/workflows/*.yml destroy-policy.txt container-proof.txt *.txt
ls -l module-09-evidence.tgz | tee evidence.txt
Expected output
destroy-checks passed; container-proof.txt shows the lab container; evidence archive created.
Validation steps¶
- Docker module plans and applies locally via
destroy-checks.sh - Plan workflow uploads
tfplanartefact - Apply workflow uses
environment: productionand saved plan -
container-proof.txtshows the lab container before destroy
Common errors and fixes¶
| Error | Cause | Fix |
|---|---|---|
Cannot connect to the Docker daemon | Docker not running | Start Docker Desktop or sudo systemctl start docker |
| Backend changed between plan/apply | Different env vars | Pin backend config in both workflows |
| Stale plan | New commits after plan | Re-plan before apply |
| State locked | Parallel workflow runs | Use concurrency group; investigate lock holder |
| Secrets in plan output | Misconfigured providers | Mark sensitive; restrict log access |
| Fork pull request exfiltration | Over-broad OIDC trust | Restrict sub claim; no secrets on forks |
Challenge exercise¶
Add a concurrency: group keyed on terraform-${{ github.ref }} to both workflows so only one Terraform run executes per branch. Extend destroy-checks.sh to grep both workflow files for the concurrency key.
Learning outcomes¶
- Ran Terraform plan/apply/destroy against real Docker resources
- Gated apply with environment protection and
workflow_dispatch - Proved the container with
docker psandcurlbefore destroy - Separated lab destroy policy from production practice via executable script
Cleanup¶
cd ~/rebash-github-actions/module-09/tf-demo
terraform destroy -auto-approve 2>/dev/null || true
docker rm -f rebash-gha-tf-lab 2>/dev/null || true
ls ~/rebash-github-actions/module-09
Validation¶
- Lab completed under
~/rebash-github-actions/module-09/ - You can explain why apply uses a saved plan file
- You can refuse auto-apply on pull request branches
- You can describe state locking's purpose
Code Walkthrough¶
- Plan always, apply rarely — artefacts plus environment approvals.
- Identical backend — plan and apply jobs must agree on backend config.
- Pull request = plan only — no production roles on untrusted code.
- Upload tfplan — auditors see what was proposed for that run.
- Destroy labs on a timer — cost control.
Security Considerations¶
- Cloud credentials in GitHub secrets are production power — scope tightly per environment.
- Plan logs can reveal sensitive attributes — limit workflow visibility.
- Environment approvals need authenticated reviewers, not open repositories.
- Remote state buckets need encryption and strict Identity and Access Management (IAM).
- Destroy workflows must not be triggerable from fork pull requests.
Common Mistakes¶
Auto-apply on every branch
Feature branches mutate production. Fix: restrict apply to main or tags plus environment protection.
Apply without -out plan file
Drift between reviewed plan and apply. Fix: terraform apply tfplan.
Long-lived admin keys for pull request CI
Leak equals account takeover. Fix: OIDC with narrow roles; plan-only on pull requests.
No destroy for labs
Bill shock. Fix: TTL plus dedicated destroy workflow.
Best Practices¶
- One root module per workflow path; use workspaces deliberately.
- Use
concurrencygroups for stateful applies. - Run policy-as-code (Open Policy Agent (OPA), tfsec, Checkov) before apply.
- Tag cloud resources with
managed-by=terraformand owner. - Promote plans across environments with explicit hand-off of artefact run IDs.
Troubleshooting¶
| Symptom | Likely cause | Fix |
|---|---|---|
| Provider auth fail | Missing OIDC/secret | Check permissions: id-token: write and role trust |
| Plan empty unexpectedly | Wrong directory | Pin working-directory or -chdir |
| Apply forbidden | Branch/environment gates | Expected for pull requests |
| Corrupt local state | Interrupted apply | Prefer remote state + lock |
| Artefact not found | Wrong run ID or retention | Re-run plan; check artefact name |
Summary¶
GitHub Actions makes Terraform reviewable: plan artefacts on pull requests, gated apply through protected environments, remote state awareness, and strict destroy rules for labs. Next: Multi-Cloud Deployments with GitHub Actions.
Interview Questions¶
1. Why upload tfplan as a workflow artefact?
Reveal answer
So the exact binary plan that was reviewed is what apply uses, and auditors can retrieve what change set was proposed for that workflow run.
2. Why should pull requests not auto-apply to production?
Reveal answer
Pull request code and untrusted authors (especially from forks) must not mutate production infrastructure. Pull requests should plan with read-only roles; apply stays on protected branches with environment approvals.
3. What problem does remote state locking solve?
Reveal answer
It prevents two concurrent Terraform runs from corrupting state by serialising writes against the same state file.
4. What is the risk of applying without a saved plan file?
Reveal answer
The apply may compute a different change set than the one humans reviewed if configuration, variables, or provider versions drifted between plan and apply.
5. How does OIDC improve on static cloud access keys in Terraform workflows?
Reveal answer
GitHub mints a short-lived JSON Web Token (JWT) per job; the cloud trusts it and returns temporary credentials, reducing long-lived secret sprawl and enabling trust policies tied to repository, branch, and environment.
6. Where should terraform destroy live in production workflows?
Reveal answer
In tightly controlled workflows with strong approvals and narrow roles — not as a casual checkbox on pull request pipelines. Labs may use destroy flags with clear TTL policies.
7. Why use workflow concurrency groups for Terraform?
Reveal answer
Concurrent applies against one state increase lock contention and human confusion; serialising reduces race risk and makes failures easier to diagnose.
8. What should a Terraform workflow do when plan fails?
Reveal answer
Fail the job, publish logs, do not run apply, and notify owners. Fix the configuration or credentials, then re-plan from a clean run.
Related Tutorials¶
- Secrets, Variables, and OIDC
- Multi-Cloud Deployments with GitHub Actions
- Terraform workflow — init, plan, apply