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

  1. fmt -check — fail the build on unformatted code so diffs stay clean.
  2. validate — catch syntax and type errors before touching the cloud.
  3. plan -out=tfplan — generate the plan on the PR and post it for a human to review.
  4. 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

Example · bash
# 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 tfplan

When 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.

Example · yaml
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.

Example · yaml
- 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.

Example · bash
# 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

  • Be the first to comment on this lesson.