Git, CI/CD & GitOpsL · Terraform CIFoundations
The Terraform CI discipline — why plan-on-PR is the right pattern
What you'll learn
- Explain why plan-on-PR is the canonical Terraform CI pattern
- Distinguish the plan job from the apply job and the boundary between them
- Identify the credentials, environment, and state access each job requires
- Recognise why apply-on-PR is rejected as the default in production infrastructure
Prerequisites
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
Terraform CI has a single shape: the plan runs on every pull request, the apply runs only on the default branch after the plan has been reviewed. Everything else in this Part - format, lint, security scans, integration tests, plan-as-artifact - exists to make that boundary safe. Get the boundary wrong and the rest of the discipline loses its meaning, because a CI system that can both plan and apply is a CI system that can change production from a pull-request merge.
The plan-on-PR pattern
The pull request is where the change is described, where reviewers are assigned, and where the conversation about the change happens. The plan is the content of that conversation: a concrete, line-by-line account of what the change will do to infrastructure. Posting the plan on the PR converts the review from “please review my code” into “please review this list of resources that will be created, modified, or destroyed”.
The canonical shape:
flowchart LR
A[Pull request opened] --> B[Plan job runs in CI]
B --> C[Plan output posted as PR comment]
C --> D[Reviewer reads code and plan]
D --> E{Approved?}
E -->|no| F[Author revises]
F --> A
E -->|yes| G[Merge to default branch]
G --> H[Apply job runs on default branch]
The plan job is allowed to read state but is forbidden from writing it. The apply job is allowed to write state but is forbidden from running on a pull request. The boundary is enforced by the CI system and the runner’s credentials, not by convention.
The apply boundary
The reason the apply does not run on the pull request is not operational inconvenience; it is a chain-of-trust problem. An apply is a write to production infrastructure. The trust required to perform that write comes from three sources: a reviewed pull request, a passing CI pipeline, and an identity authorised to mutate state. The pull-request event provides the first; the protected default branch provides the second; the runner’s short-lived credentials provide the third.
If apply runs on the pull request, a malicious or careless contributor who opens a PR can have infrastructure changes applied before a reviewer has seen the plan. The state is mutated, the audit log names a runner identity, and the review becomes retroactive. This is the canonical supply-chain failure mode for Terraform repositories.
What each job needs
The two jobs have asymmetric requirements:
| Concern | Plan job (PR) | Apply job (default branch) |
|---|---|---|
| State read access | yes | yes |
| State write access | no | yes |
| Cloud credentials | read-only | read-write |
| Trigger | pull request | push to default branch |
| Human approval | required for merge | required by environment protection |
| Time | seconds to minutes | minutes to hours |
The asymmetry is the point. A reviewer who approves a PR is approving a plan, not an apply. The apply is a separate event, gated by a separate identity, and recorded in a separate audit entry. Conflating them collapses the review boundary into the merge button, which is exactly the failure mode the pattern is designed to prevent.
Production discipline
- Plan on every PR, apply on default branch. This is the default. Departures from it require a written exception reviewed by the platform team.
- State read access is fine on PRs; state write access is not. Configure separate IAM roles for the plan and apply jobs.
- The plan that is reviewed must be the plan that is applied. Saving the plan as an artifact and applying that artifact, rather than re-planning, is the contract (covered in lesson L-06).
- Apply jobs run on protected environments with required reviewers. A direct
terraform applyfrom a developer laptop is a CI bypass and an audit-trail break.
Cross-course references
- Terraform for Production Sysadmins - Part IX (State) covers remote state backends, which the plan job reads from and the apply job writes to.
- Terraform for Production Sysadmins - Part XII (StateLocking) covers state locking, which is what prevents two apply jobs from racing each other.
- This course, Part XLII (Secrets) - short-lived OIDC credentials are the standard way to give plan and apply jobs the access they need without storing long-lived cloud keys.
- This course, Part XLVIII (Conditions) - environment protection rules gate the apply job behind required reviewers.
Quiz
Knowledge check · 4 questions
Q1. A team wants CI to apply Terraform changes automatically when a pull request is merged. What is the production-safe way to express this?
Q2. A pull-request job that only runs terraform plan still requires write access to Terraform state.
Q3. Name the three sources of trust that combine to authorise a Terraform apply in CI, and identify which one is missing if the apply runs on a pull_request event.
Q4. Diagnose a Terraform supply-chain incident and identify the boundary that was crossed.
A team configures a single CI job that runs terraform plan and then terraform apply on every pull_request event. A contributor opens a PR that renames a security group and adds a new ingress rule allowing 0.0.0.0/0 to port 22. The plan is generated but the reviewer is asleep. The PR is approved by a bot account the contributor controls, and the apply runs. Within five minutes the security group is open to the internet.
Passing score: 75%. Answers are checked in this browser.