Skip to main content
RunBook Academy

Git, CI/CD & GitOpsCIX · Terraform Delivery PipelineFormatAndValidate

fmt and validate in CI — the cheapest checks

Advanced⏱ ~24 mingitterraform

What you'll learn

  • Run terraform fmt -check -recursive -diff as the first CI gate and explain each flag
  • Run terraform init -backend=false then terraform validate -json as the second gate
  • Distinguish errors from warnings in the validate JSON output and surface both
  • Identify the cost-per-rejection ordering that makes these the cheapest checks in the pipeline

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

Not yet marked complete on this device.

The first two stages of a production Terraform pipeline are the two commands built into the Terraform CLI: terraform fmt and terraform validate. They are deliberately narrow, deliberately fast, and deliberately side-effect-free. Their job is not to certify a change as safe; their job is to fail in under thirty seconds on the class of mistakes that should never reach a reviewer, a security scanner, or the state backend.

The fmt stage

terraform fmt rewrites HCL files to match the canonical HashiCorp style: aligned equals signs, two-space indentation, consistent block ordering, normalised quoting. Run with -check, it reports rather than rewrites. Run with -recursive, it walks every .tf file under the working directory. Run with -diff, it prints the changes it would have made.

The command that belongs in CI:

terraform fmt -check -recursive -diff
flowchart LR
    A["Working tree HCL"] --> B["fmt -check"]
    B --> C{"Well-formed?"}
    C -->|"yes"| D["Pass, exit 0"]
    C -->|"no"| E["Print diff, exit 1"]
    E --> F["Author runs terraform fmt locally"]
    F --> A

Three properties of this invocation matter:

  • -check makes fmt a reporter, not a writer. A CI job that rewrites the developer’s working tree is a CI job that disagrees with the developer about what changed. The pipeline reports; the developer fixes.
  • -recursive covers monorepo layouts. A repository with multiple Terraform modules needs all of them checked, not just the working directory.
  • -diff makes the failure self-documenting. A reviewer who sees “fmt failed” learns nothing; a reviewer who sees the proposed diff learns what to fix and where.

What fmt does not do: it does not parse HCL, it does not validate references, it does not check provider schemas, and it does not enforce team conventions that the canonical style leaves undecided. A file that passes fmt -check can still be syntactically invalid, semantically broken, or operationally dangerous.

The validate stage

terraform validate parses every .tf and .tf.json file, resolves internal references (resource addresses, variable references, output references), and checks the result against the provider schemas downloaded by terraform init. The command requires a populated .terraform/ directory, which means validate runs after init in the pipeline.

The init that belongs on the PR job:

terraform init -backend=false

The -backend=false flag prevents init from authenticating to the remote state backend. The PR job therefore needs no state credentials and cannot accidentally touch state. The validate that follows:

terraform validate -json

The -json flag emits a structured document distinguishing errors from warnings. A minimal CI integration parses valid, error_count, warning_count, and diagnostics[] (each with severity, summary, detail). Errors fail the build; warnings post as a separate check.

What fmt and validate do not catch

The two commands are deliberately scoped. The classes of mistake they miss are precisely the classes the rest of this part of the course exists to address:

  • Deprecated or removed provider attributes. validate checks the schema it has downloaded; a provider upgrade that removes an attribute is caught by the next terraform plan.
  • Unused variables and dead code. validate does not warn about a variable that is declared but never referenced.
  • Security misconfiguration. A security group open to 0.0.0.0/0 passes both fmt and validate cleanly.
  • Cross-module coupling. validate operates on a single configuration directory. A reference to an output another module stopped exporting passes locally and fails at plan time.
  • Drift between state and configuration. Neither command reads state by default. Drift is a terraform plan concern, addressed in lesson git-cicd-gitops-cix-06.

Ordering matters

The cost of a rejection grows as the pipeline advances. A fmt rejection costs seconds. A validate rejection costs the time to download providers. A plan rejection costs the time to download providers, read remote state, and refresh attributes. A rejected apply costs a partial state mutation.

flowchart LR
    A["fmt - seconds"] --> B["init + validate - tens of seconds"]
    B --> C["plan - minutes"]
    C --> D["policy gate - seconds"]
    D --> E["human approval - hours"]
    E --> F["apply - minutes, mutates state"]

Placing fmt first and validate second is the cheapest order of gates: the cheapest mistakes are caught by the cheapest stages, and the most expensive stage (apply) only runs if every preceding gate passed.

Production discipline

  1. fmt runs first, with -check -recursive -diff, and exits non-zero on any formatting drift. The diff is posted to the PR as a comment so the author can apply it.
  2. Validate runs after init -backend=false. The PR job holds no state credentials; the validate job cannot touch state even by accident.
  3. Validate output is JSON. The human-readable form is for terminals; the CI system parses the structured form and distinguishes errors from warnings.
  4. Warnings post as a separate check. They do not fail the build; they accumulate as a backlog and can be promoted to errors by deliberate decision.
  5. fmt and validate are not skipped on -fast or label-based branch filters. The whole point of the gates is that they are cheap; skipping them saves nothing and loses everything.

Cross-course references

  • Terraform for Production Sysadmins - Part IV (HCL Syntax) covers the language rules validate enforces.
  • Terraform for Production Sysadmins - Part VII (Providers) covers the provider schemas validate resolves against.
  • This course, Part L (TerraformCI) - lessons git-cicd-gitops-l-02 and git-cicd-gitops-l-03 cover fmt and validate at the intermediate level.
  • This course, Part XLIX (InfrastructureCI) - the general framing of format-as-first-gate.

Quiz

Knowledge check · 4 questions

  1. Q1. Why does the PR job run `terraform init -backend=false` rather than a plain `terraform init`?

  2. Q2. A passing `terraform validate` confirms that a change is correct, secure, and ready to apply.

  3. Q3. List the three flags passed to `terraform fmt` in CI and explain the role of each.

  4. Q4. Diagnose why a fmt+validate pipeline allowed a security misconfiguration to reach production.

    A team runs fmt and validate as their only Terraform CI gates on pull requests. A contributor adds a security group ingress rule that exposes port 22 to `0.0.0.0/0`. The PR passes fmt and validate, the team merges it, and the apply runs. A week later, a security audit flags the rule and the team concludes that fmt and validate are unreliable.

Passing score: 75%. Answers are checked in this browser.