Engineering Notes
Home/DevOps & Cloud/Terraform/Terraform in CI/CD
DevOps & Cloud · Terraform

Terraform in CI/CD

Run Terraform from a pipeline, not laptops: plans posted on pull requests, approvals, then apply of exactly the reviewed plan using short-lived OIDC credentials. Follow one change from PR to production, including drift detection.

☁️ DevOps & Cloud● Intermediate⏱ 4 min read▶ 2 interactive🛠️ 2 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Why pipelines beat laptops for Terraform
  • The standard flow: fmt/validate → plan on PR → approve → apply saved plan
  • OIDC credentials instead of long-lived keys
  • Monorepo change detection and scheduled drift detection

1Why Run Terraform in a Pipeline

Running terraform apply from laptops means: unreviewed changes, inconsistent Terraform/provider versions, powerful credentials on personal machines, no audit trail, and "it worked for me" state conflicts. A pipeline gives every infrastructure change the same path as application code: pull request → automated checks → reviewed plan → approved apply (devops-cloud/cicd/01-ci_cd_fundamentals).

2The Standard Flow

text
pull request opened/updated
  ├─ fmt -check, validate, tflint, security scan (checkov/trivy)       (09-testing_and_policy_as_code/)
  ├─ terraform plan -out=tfplan  (per changed unit/environment)
  ├─ post the plan summary as a PR comment; upload tfplan as an artifact
  └─ policy checks on the plan (OPA/Conftest, Sentinel)
review + approve the PR (CODEOWNERS for infra)
merge to main
  ├─ dev/staging: terraform apply tfplan (or re-plan + apply) automatically
  └─ prod: wait for manual approval (protected environment) → apply the reviewed plan
scheduled (nightly): terraform plan -detailed-exitcode on every unit → alert on drift

Apply what was reviewed: either apply the saved plan file from the PR (if nothing changed since), or re-plan on main and require the new plan to be approved before apply. Applying a fresh plan nobody looked at defeats the purpose.

3A GitHub Actions Example

yaml
name: terraform
on:
  pull_request: { paths: ["infra/**"] }
  push: { branches: [main], paths: ["infra/**"] }

permissions: { contents: read, id-token: write, pull-requests: write }

jobs:
  plan:
    runs-on: ubuntu-24.04
    defaults: { run: { working-directory: infra/live/prod/services } }
    concurrency: { group: tf-prod-services }      # one run per unit at a time
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with: { terraform_version: 1.9.8 }
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111111111111:role/tf-plan-prod   # READ-ONLY role for plans
          aws-region: eu-west-1
      - run: terraform init -input=false
      - run: terraform fmt -check -recursive
      - run: terraform validate
      - run: terraform plan -input=false -no-color -out=tfplan
      - run: terraform show -no-color tfplan > plan.txt
      - uses: actions/upload-artifact@v4
        with: { name: tfplan-prod-services, path: infra/live/prod/services/tfplan }
      # post plan.txt as a PR comment (e.g. actions/github-script)

  apply:
    if: github.ref == 'refs/heads/main'
    needs: plan
    environment: production                        # required reviewers configured in repo settings
    runs-on: ubuntu-24.04
    steps:
      # checkout, setup-terraform, credentials with role tf-apply-prod (WRITE), init, download artifact
      - run: terraform apply -input=false tfplan

4Credentials

  • Use OIDC federation from the CI provider to the cloud (no long-lived access keys in CI secrets).
  • Separate roles: a read-only plan role (can run on PRs, including from branches) and a write apply role usable only from the protected branch/environment (IAM trust policy conditions on the OIDC sub claim, e.g. repo:acme/infra:environment:production).
  • Never run plan for untrusted fork PRs with secrets available — a malicious external data source or provider could exfiltrate credentials.
Long-lived keys in CI secrets
yaml
env:
  AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET }}
# valid forever, copied around, admin by default
OIDC role per environment
yaml
permissions: { id-token: write, contents: read }
- uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::111111111111:role/tf-plan
    aws-region: eu-west-1
# 1-hour credentials, trust limited to this repo + branch

5Monorepos: Plan Only What Changed

With many units, detect changed directories (path filters, git diff) and plan only those — plus units depending on changed shared modules. Tools: Atlantis (PR-driven plan/apply via comments, locking per directory), Terragrunt (run-all with dependency ordering), HCP Terraform, Spacelift, env0, Digger.

6Drift Detection

terraform plan -detailed-exitcode exits 0 (no changes), 2 (changes present) or 1 (error). Run it on a schedule for every unit; exit code 2 on main means reality drifted (console changes, out-of-band automation) → alert the owning team, then codify or revert (03-state_and_remote_backends/ exercise 2, incident C).

Under the HoodWhat a Saved Plan Contains▾

terraform plan -out=tfplan writes a binary file (a zip) containing the full planned changes, the prior state snapshot it was based on, the configuration, the variable values (including sensitive ones — protect the artifact), and the provider and Terraform version requirements. terraform apply tfplan executes exactly those changes without re-planning — but only if the state hasn't changed since: if another apply modified the state in between (its serial/lineage differs), Terraform refuses with "Saved plan is stale". That's the mechanism that makes "apply the reviewed plan" safe; combined with a concurrency group (one run per unit) it prevents two pipelines from racing on the same state.

A pull request runs format, validate, lint, security scan and a plan with a read-only role, posts the plan for review and policy checks; after merge, dev applies automatically and prod applies the reviewed plan after approval using an OIDC write role, while a nightly plan detects drift
A pull request runs format, validate, lint, security scan and a plan with a read-only role, posts the plan for review and policy checks; after merge, dev applies automatically and prod applies the reviewed plan after approval using an OIDC write role, while a nightly plan detects drift

⚠️ Common Pitfalls

  1. Applying a plan nobody reviewed (re-plan on main without approval).
  2. Long-lived admin keys in CI secrets — use OIDC + scoped roles.
  3. No concurrency control — two applies on one unit.
  4. Running plans for fork PRs with credentials.
  5. Plan artifacts publicly downloadable — they contain variable values.
  6. No drift detection — the code silently stops describing reality.

📋 Quick Reference

text
PR: fmt -check · validate · tflint · checkov · plan -out (read-only role) · comment plan · policy check
main: apply reviewed plan (dev auto, prod approval, write role via OIDC) · concurrency per unit
nightly: plan -detailed-exitcode (2 = drift) → alert
tools: Atlantis · Terragrunt · HCP Terraform · Spacelift

🧠 Key takeaways

  • Pipelines give reviewable plans, audit trails, consistent tooling and no laptop credentials.
  • Flow: static checks → plan on PR (posted) → approval → apply the saved plan.
  • Use OIDC short-lived roles, separate plan (read) and apply (write) roles per environment.
  • Plan only changed units in monorepos; schedule drift detection with -detailed-exitcode.

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What does a Terraform CI/CD pipeline look like?core▾

On PR: fmt check, validate, lint, security scan, plan with output posted to the PR. On merge: apply the saved plan (or re-plan and apply) with approvals for production, using OIDC credentials and state locking.

Follow-ups interviewers ask
  • Why save the plan file? So exactly the reviewed changes are applied.
Q2How do you authenticate CI to the cloud?core▾

OIDC federation (GitHub Actions/GitLab → AWS IAM role with conditions on repo and branch), separate roles for plan (read-only) and apply, no long-lived keys.

Follow-ups interviewers ask
  • Why separate plan and apply roles? PR plans from untrusted branches shouldn't have write access.
Q3What tools help run Terraform collaboratively?senior▾

Atlantis (plan/apply via PR comments), Terraform Cloud/Enterprise, Spacelift, env0, or plain CI with careful locking.

Follow-ups interviewers ask
  • Benefit of Atlantis? Locks per directory and keeps apply tied to PR review.
Q4How do you detect drift?core▾

Scheduled terraform plan -detailed-exitcode jobs that alert when changes are detected (exit code 2), then reconcile by updating code or reverting manual changes.

Follow-ups interviewers ask
  • What causes drift? Console changes, other tools, or autoscaling-managed attributes.
Q5How do you handle dangerous changes?core▾

Review plans for destroys/replacements, prevent_destroy on critical resources, policy checks blocking deletions, and manual approval gates for production.

Follow-ups interviewers ask
  • What does 'forces replacement' mean? The change requires destroying and recreating the resource.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.

✅ Check yourself

Answer the questions — feedback is instant.

1. Why apply a saved plan file instead of running apply again?
Reviewed = applied.
2. Best credential mechanism for CI?
Short-lived, scoped trust.
3. terraform plan -detailed-exitcode returns 2 when…
0 = no changes, 1 = error, 2 = changes.
4. What should reviewers look at in a Terraform PR?
Plans show the real impact.