Testing & CI¶
A reviewer approves a plan that shows one change: bump an instance type. Before the merge pipeline applies
it, someone else's unrelated change lands first — and a naive pipeline that reruns terraform plan &&
terraform apply on merge now computes a fresh plan nobody reviewed. What runs is not what was approved.
The course page shows terraform plan and terraform apply run by hand; this page slows down on what
changes when those same commands run in a pipeline instead of a terminal — the fast local checks that
should run before any plan, the plan/apply symmetry a safe pipeline depends on, where a policy-as-code
gate fits, and how a plan gets promoted from one environment to the next.
TL;DR — in 30 seconds:
fmt -checkandvalidateneed no state and no cloud credentials — they're the cheapest possible gate, and a pipeline should fail fast on either before spending time on a realplan.- Plan/apply symmetry is the fix for "the plan a human approved isn't the plan that applies": save the
plan to a file on the PR run (
-out=tfplan) and apply that exact file on merge, never a freshly computed one. - Promotion moves the same commit forward through dev → staging → production against each environment's own state — the config never changes between environments, only which state/variables it plans against.
1. fmt and validate — the checks that need no state and no credentials¶
Before a pipeline ever touches a backend or a cloud API, two checks catch most mistakes for free:
| Command | Checks | Needs state? | Needs provider credentials? |
|---|---|---|---|
terraform fmt -check |
Canonical formatting (indentation, spacing, alignment) | No | No |
terraform validate |
Syntax, argument types, required-argument presence, internal reference resolution | No | No |
Both run against the config alone — no backend lock, no API call, no state read. That makes them the
cheapest possible gate: a pipeline should fail fast on either one before spending time or credentials on a
real plan. Neither one catches anything about the live infrastructure — a config can be perfectly
formatted and valid and still plan to destroy a production database. That's what the next two stages are
for.
2. plan in CI — the same plan you review is the one that applies¶
Run by hand, plan and apply are two commands typed moments apart by the same person. In a pipeline, the
gap between them can span a code review, so a naive pipeline that runs terraform plan on the pull request
and a separate terraform plan && terraform apply on merge has a gap: the infrastructure — or someone
else's merged change — can drift in between, and the plan a human approved is not the plan that actually
applies.
The fix is plan/apply symmetry: the pipeline saves the plan to a file on the pull-request run
(terraform plan -out=tfplan), uploads that file as a build artifact, and the merge run applies that
exact saved file (terraform apply tfplan) rather than computing a fresh one. Whatever a reviewer
approved is byte-for-byte what runs.
flowchart LR
PR["Pull request opened"]
FV["fmt -check / validate"]
Plan["terraform plan -out=tfplan"]
Artifact[("tfplan artifact")]
Policy["Policy-as-code check"]
Review["Human review of plan output"]
Merge["Merge to main"]
Apply["terraform apply tfplan"]
PR --> FV --> Plan --> Artifact
Plan --> Policy --> Review --> Merge --> Apply
Artifact --> Apply
If the target branch moves before the merge run fires, the saved plan can go stale (the backend detects
this — applying a plan against a changed state refuses rather than silently diverging). The response is to
re-run plan and go through review again, never to force an apply through a stale plan.
3. Policy-as-code — an automated gate between plan and human review¶
fmt/validate/plan all answer "is this config well-formed and what would it do" — none of them answer
"are we allowed to do this." Policy-as-code closes that gap by evaluating the plan's structured output
(most tools can emit the plan as JSON) against a set of organization rules before a human ever looks at it:
- No security group or storage bucket left open to the public internet.
- No destroy or replace of a resource tagged as production-critical.
- Every resource carries the required cost-tracking or ownership tags.
- Instance sizes, regions, or resource types stay within an approved list.
A policy failure blocks the pipeline the same way a failed test blocks a normal CI run — the change cannot merge until the plan satisfies the rules or someone with the authority to override does so explicitly. This turns rules that used to live in a review checklist into something the pipeline enforces on every single plan, not just the ones a reviewer happens to catch.
4. Promotion across environments — the same plan, applied forward¶
A pipeline typically manages more than one environment (dev, staging, production) from the same configuration, each with its own state (own backend key, own variable values). Promotion is the practice of moving a change through those environments in order rather than applying it to all of them at once:
- Merge lands → pipeline plans and applies against dev's state.
- Passing dev (automated checks, or a manual approval step) promotes the same commit — not a new plan authored by hand — to plan and apply against staging's state.
- Staging passing promotes the identical commit to production's state, usually gated by an explicit approval.
flowchart LR
Merge["Merge to main<br/>(one commit)"]
Dev["Plan + apply<br/>against dev state"]
Staging["Same commit —<br/>plan + apply against<br/>staging state"]
Prod["Same commit —<br/>plan + apply against<br/>production state<br/>(explicit approval)"]
Merge --> Dev -->|Dev passes| Staging -->|Staging passes| Prod
The commit — and therefore the config — never changes between environments; only which state/variables it plans against does. That's what makes "promotion" meaningfully different from "reapplying by hand in three places": the exact change that passed dev is the exact change reaching production, with no chance for someone to hand-edit staging's config on the way through.
5. How this connects¶
The course page's terraform plan and terraform apply are the primitives; this page is the discipline
around running them unattended: fmt/validate catch malformed config for free before any state is
touched, plan/apply symmetry guarantees the reviewed plan is the applied plan, a policy-as-code gate
enforces organizational rules the plan itself can't know about, and promotion moves one plan through
environments in order instead of re-authoring it at each stage.
Common gotchas
- Re-running
terraform planon merge instead of applying the file the reviewer already approved. Fix: save the plan (-out=tfplan) on the PR run andapply tfplanon merge — anything else risks applying a change nobody actually reviewed. - Treating a policy-as-code failure as optional. Fix: it's an automated gate for "are we allowed to do this," not "is this well-formed" — a plan can be perfectly valid and still violate an org rule (public bucket, missing tag); block the merge the same way a failed test would.
- Hand-editing staging's config to "get it working" during a promotion. Fix: promotion's whole guarantee is that the identical commit that passed dev reaches production — a hand-edit at any stage breaks that guarantee and makes staging no longer represent what will actually ship.
- Assuming a stale saved plan can just be force-applied. Fix: the backend detects if state moved since the plan was saved and refuses to apply it — the correct response is to re-plan and go through review again, never to force it through.
Check yourself — a pipeline runs terraform plan on the PR and a separate terraform plan && terraform apply on merge. What's the risk, even if both plans look identical on screen?
The merge run computes a fresh plan against whatever state/config exists at merge time, which may have drifted (or been changed by someone else's merge) since the PR's plan was reviewed — the plan a human approved is not guaranteed to be the plan that actually applies.
Check yourself — production, staging, and dev all run from the same Terraform config. What's different about each environment's execution, and what stays identical?
Each environment has its own backend/state key and its own variable values, and only that changes — the config (the exact commit) stays identical across all three, which is what makes "promotion" meaningfully different from re-authoring or hand-editing a plan per environment.
You can defend this when you can say why a CI pipeline should never re-plan between review and apply,
name at least two things fmt/validate catch that a human reviewer might miss, describe what a
policy-as-code check adds beyond plan output, and explain why promotion applies the same commit forward
rather than a fresh plan per environment.
Go deeper: the full plan/apply-in-automation workflow — including saved-plan-file handling across separate machines — is documented in HashiCorp's Running Terraform in Automation guide — this page covers the 80/20 you need for the Do exercise.