02 · CI/CD for Terraform¶
Level 3 module 01 recommended routing applies through CI rather than
laptops. This module builds that pipeline concretely with GitHub Actions —
plan-on-PR, apply-on-merge, using the -detailed-exitcode and
-out=tfplan patterns from Level 1 module 09 and the policy check from
Level 3 module 05.
The two-workflow pattern: plan on PR, apply on merge¶
# .github/workflows/terraform-plan.yml
name: Terraform Plan
on:
pull_request:
paths: ["envs/prod/**", "modules/**"]
jobs:
plan:
runs-on: ubuntu-latest
defaults:
run:
working-directory: envs/prod
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.9.5"
- run: terraform init
- run: terraform fmt -check
- run: terraform validate
- run: terraform plan -no-color -out=tfplan
- run: terraform show -no-color tfplan > plan.txt
- uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const plan = fs.readFileSync('envs/prod/plan.txt', 'utf8');
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: "```\n" + plan.slice(0, 60000) + "\n```",
});
Every pull request touching envs/prod or modules/ gets an automatic
comment showing exactly what terraform plan would do — this is Level 3
module 04's "attach the plan to the PR for review" practice, automated so
it can never be forgotten or run against a stale checkout.
# .github/workflows/terraform-apply.yml
name: Terraform Apply
on:
push:
branches: [main]
paths: ["envs/prod/**", "modules/**"]
jobs:
apply:
runs-on: ubuntu-latest
environment: production # requires manual approval via GitHub Environments
defaults:
run:
working-directory: envs/prod
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.9.5"
- run: terraform init
- run: terraform apply -auto-approve
environment: production ties this job to a GitHub Environment configured
with required reviewers — the job pauses, waiting for a human approval
click, before terraform apply -auto-approve actually runs. This is the
"CI pipeline with its own review gate" case Level 1 module 09 flagged as
the legitimate use of -auto-approve: the approval gate replaces the
interactive terminal prompt, rather than removing review entirely.
Injecting the policy check (Level 3 module 05) into the pipeline¶
- run: terraform show -json tfplan > plan.json
- run: |
curl -sL https://github.com/open-policy-agent/conftest/releases/download/v0.53.0/conftest_0.53.0_Linux_x86_64.tar.gz | tar xz
./conftest test plan.json --policy policy/
Adding two steps between plan and apply runs the OPA/Rego policies
from Level 3 module 05 as a real CI gate — a failing conftest test
fails the workflow step, which (by GitHub Actions' default job-failure
behavior) prevents the workflow from proceeding to any later apply step
in the same job, with zero code needed to wire the failure into the
pipeline's control flow.
Credentials: OIDC over long-lived keys¶
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-terraform
aws-region: us-east-1
Rather than storing a long-lived AWS access key as a GitHub secret,
configure-aws-credentials with role-to-assume uses GitHub's own OIDC
token to assume a short-lived IAM role scoped to this specific repository
and workflow — directly extending Level 3 module 08's secrets-management
principle ("never hand out a plaintext long-lived credential when a
narrower, ephemeral one will do") to the CI system itself.
Worked example: the full plan→policy→approve→apply chain¶
PR opened, touches envs/prod/
-> terraform-plan.yml runs: fmt, validate, plan, posts plan as PR comment
-> reviewer reads the plan comment, approves the PR
PR merged to main
-> terraform-apply.yml triggers on push to main
-> plan + policy check (conftest) run again, against the merged code
-> GitHub Environment pauses for required-reviewer approval
-> approved -> terraform apply -auto-approve runs
Notice the plan is computed twice — once for review on the PR, once
again on main right before apply. This is deliberate: the PR's plan
could be stale by the time of merge (another PR may have merged and
applied in between), so re-planning against main's actual current state
immediately before apply is what guarantees the applied plan matches
current reality, exactly the discipline Level 1 module 09's -out=tfplan
section described for avoiding drift between review and execution.
How It Actually Works¶
Nothing about running Terraform inside CI changes Terraform Core's own
execution model — a GitHub Actions runner is simply another machine
invoking the same CLI, against the same backend, subject to the same
state locking from Level 3 module 01. This is precisely why the
two-plan pattern above is necessary rather than paranoid: the PR-time
plan and the merge-time plan are two entirely separate terraform plan
invocations, potentially minutes or hours apart, and Terraform gives no
guarantee whatsoever that a plan computed at time T is still valid at
time T+1 unless you re-plan (or apply from a saved plan file
immediately, with no gap, which the merge-triggered workflow effectively
approximates by planning and applying within the same job run).
-out=tfplan followed by apply -auto-approve (with no plan file
argument) versus apply tfplan (with one) are meaningfully different
even inside CI — the pipeline above deliberately re-plans rather than
replaying a saved file from the PR stage, because a plan file cannot
safely cross job boundaries without re-validating against current state
anyway. A saved plan file, per Level 1 module 09's "How It Actually
Works," is a frozen, serialized set of provider RPC payloads that
Terraform refuses to apply if state has changed since it was generated —
so even if this pipeline did try to carry the PR-time plan file forward
to the apply job, a merge to main between PR-approval and apply would
correctly cause Terraform to reject it outright, which is exactly the
safety property state locking and plan-file validation exist to provide
across this kind of multi-stage, human-gated pipeline.
OIDC-based role assumption shifts the trust boundary from "a stored secret exists and must never leak" to "a short-lived token is minted per-run and expires automatically" — mechanically, GitHub's runner requests a signed JWT from GitHub's own OIDC provider, and AWS STS verifies that JWT's signature and claims (repository, branch, workflow) against the trust policy on the target IAM role before issuing temporary credentials. No credential of any kind exists before the workflow starts or after it ends, which is a fundamentally different risk profile than a GitHub Actions secret containing a long-lived access key — a leaked OIDC token is worthless outside the exact run that requested it, while a leaked long-lived key remains valid until someone notices and rotates it.
Exercise¶
Extend the terraform-plan.yml workflow above with a step that fails the
job if terraform fmt -check finds unformatted files, positioned before
the terraform plan step — and explain in one sentence why running
fmt -check before plan (rather than after) is the more useful ordering
for a contributor reading the CI failure.