In a team nobody runs apply from a laptop. The pipeline does, and the plan that was reviewed is exactly the plan that gets applied.
flowchart LR PR["Pull request"] --> CK["fmt, validate, tflint, test"] CK --> PL["plan -out=tfplan"] PL --> CM["Plan posted on the PR"] CM --> RV["Review + approval"] RV --> MG["Merge to main"] MG --> AP["apply tfplan (protected environment)"] %% step PR: A change starts as a pull request; branch protection forces the pipeline to run. %% step CK: Static checks and tests run first because they are fast and need no credentials. %% step PL: plan -out saves the exact set of changes to a binary file. %% step CM: The pipeline posts terraform show output as a comment so reviewers read the real diff. %% step RV: A second person approves both the code and the plan. %% step AP: Apply uses the saved plan; if the state changed since, Terraform refuses it as stale.
The key commands
export TF_IN_AUTOMATION=1
terraform init -input=false
terraform plan -input=false -out=tfplan
terraform show -no-color tfplan > plan.txt # human-readable, for the PR comment
terraform show -json tfplan > plan.json # machine-readable, for policies (4.4)
terraform apply -input=false tfplan # no prompt: a saved plan is already approved
A saved plan contains the full configuration and variable values, so it may hold secrets: keep it as a short-lived, access-controlled artifact, never in a public place.
GitHub Actions sketch
name: terraform
on:
pull_request:
push:
branches: [main]
permissions:
id-token: write # OIDC token for the cloud role
contents: read
pull-requests: write
concurrency:
group: tf-dev # one run per state at a time
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with: { terraform_version: 1.13.4 }
- run: terraform init -input=false
- run: terraform plan -input=false -out=tfplan
- run: terraform show -no-color tfplan > plan.txt
# post plan.txt as a PR comment, upload tfplan as an artifact
The apply job runs on main in a protected environment with required reviewers, downloads the artifact and runs terraform apply tfplan. Many teams use Atlantis, HCP Terraform or Spacelift instead of hand-written workflows; the flow is the same.
Credentials
- Use OIDC: the CI job exchanges its identity token for short-lived cloud credentials (AWS role, Azure federated credential, GCP workload identity federation). No long-lived keys in CI secrets.
- Give the plan job read-only rights where possible and the apply job write rights, only on the protected branch.
Traps: applying from a fresh plan on merge instead of the reviewed saved plan means the applied changes can differ from what was approved; and two pipelines applying the same state at once wait on the lock or fail, which is why concurrency groups matter.