CI/CD Gates: fmt, validate, plan, apply
Let a pipeline enforce formatting, catch errors, post the plan for review, and apply a saved plan on merge.
The goal of a Terraform pipeline is simple: no human ever runs apply from their laptop against production. The pipeline is the only path, and it gates every change.
The four stages
fmt -check— fail the build on unformatted code so diffs stay clean.validate— catch syntax and type errors before touching the cloud.plan -out=tfplan— generate the plan on the PR and post it for a human to review.apply tfplan— on merge, apply the exact saved plan, so what was reviewed is what runs.
The detail people miss
Applying a saved plan file, not re-planning at apply time, is what guarantees the reviewed change and the executed change are identical. Re-planning at apply can silently pick up new drift.
Example
# Runs on every pull request
terraform fmt -check -recursive # clean formatting or fail
terraform init -input=false
terraform validate # syntax + type checks
terraform plan -input=false -out=tfplan
# Post the plan to the PR for review
terraform show -no-color tfplan > plan.txt
# Runs only after merge — apply the EXACT reviewed plan
terraform apply -input=false tfplanWhen to use it
- A GitHub Actions pipeline blocks the merge if terraform validate exits non-zero, catching configuration errors before they reach the plan stage.
- An Atlantis server posts the plan diff as a PR comment and requires a maintainer to comment 'atlantis apply' before executing the apply, adding human gating.
- A GitLab CI pipeline saves the plan as an artifact, requires a manual approval action in the UI, then applies the saved plan file in the deploy job.
More examples
Four-stage CI pipeline
A GitLab CI pipeline with fmt, validate, plan, and a manual-gate apply stage that only runs on the main branch.
stages:
- validate
- plan
- review # manual gate
- apply
fmt-check:
stage: validate
script: terraform fmt -check -recursive
validate:
stage: validate
script: terraform validate
plan:
stage: plan
script:
- terraform plan -out=tfplan
artifacts:
paths: [tfplan]
apply:
stage: apply
script: terraform apply tfplan
when: manual
only: [main]Post plan to PR comment
Posts the Terraform plan output as a GitHub PR comment so reviewers see the planned changes before approving the merge.
- name: Terraform Plan
id: plan
run: terraform plan -no-color 2>&1 | tee plan_output.txt
- name: Comment plan on PR
uses: actions/github-script@v7
with:
script: |
const plan = require('fs').readFileSync('plan_output.txt', 'utf8');
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '```\n' + plan.slice(-65000) + '\n```'
});Apply only a saved plan file
Saves the binary plan and applies only that file in the deploy stage, guaranteeing the exact reviewed diff is what runs in production.
# Plan stage
terraform plan -out=tfplan.bin
# Persist tfplan.bin as a CI artifact...
# Apply stage (after human approval)
terraform apply tfplan.bin
# Applies exactly the plan that was reviewed
# Any new drift since plan time is ignored
Discussion