ADR 021: TFLint (Terraform Linting)

Accepted
2026-07-20

This legacy URL now resolves to the canonical ADR.

Context

The Terraform layer (infra/public-platform/, infra/bootstrap/) currently has two deterministic checks, and both leave a gap between them:

  • terraform fmt + terraform validate (ADR 010) enforce formatting and catch what the language itself considers an error. validate is deliberately permissive: it accepts deprecated syntax, unused variable/local/output declarations, missing required_version constraints, and provider argument values that only fail at apply time.
  • Trivy's IaC config scan (ADR 047) looks for security misconfigurations, exposed resources, excessive permissions, not correctness or hygiene.

Mapping this onto the determinism axis from ADR 048 (SonarQube), the way ADR 049 (zizmor) did for workflows:

Terraform securityTerraform correctness/hygiene
DeterministicTrivyvalidate only (permissive), gap
Non-deterministicAI reviewersAI reviewers

The practical consequence of the gap: dead declarations accumulate silently as infrastructure evolves (the exact class of drift that erodes iteration speed), and a mistyped provider argument or deprecated interpolation is only caught by a human, an AI reviewer that may or may not flag it, or a failed apply.

Decision

Adopt TFLint with its bundled terraform ruleset on the recommended preset, for both Terraform roots. Like the other linters it is managed through mise so CI and laptop run the same pinned version:

  • A .tflint.hcl per Terraform root enables the bundled ruleset. No plugin downloads, so the lint needs no network and no tflint --init in CI.
  • mise run //infra/public-platform:lint:tflint / //infra/bootstrap:lint:tflint tasks, wired into the root lint aggregate, each root's check aggregate, and lint-staged so a finding blocks the commit locally before it blocks the PR.
  • A dedicated TFLint CI workflow, triggered by any *.tf or .tflint.hcl change across the monorepo. It runs as a blocking check (the actionlint role from ADR 049, not the Security-tab role), because lint findings here are correctness issues to fix, not risk findings to triage. A separate workflow rather than a job in Infrastructure CI keeps two properties: the lint needs no Terraform Cloud token and no production-infra environment, and infra/bootstrap/-only changes don't trigger a no-op plan of infra/public-platform/ behind that environment's gate.

This gives infra/bootstrap/** its first pull-request check of any kind (its own workflow is workflow_dispatch-only).

Why It Adds Something New

TFLint is the specialist for the Terraform-correctness quadrant, and its ruleset is disjoint from what already runs: validate accepts what TFLint flags (unused declarations, deprecated syntax, missing version constraints), and Trivy's rules are security-shaped rather than hygiene-shaped. The overlap with either existing tool is effectively zero, mirroring the zizmor-vs-generalists reasoning in ADR 049: generalists each carry a rule or two about Terraform hygiene; TFLint carries the whole ruleset.

Alternatives Considered

Weighted by the same test as ADR 047 and ADR 049: deterministic, free, no new platform, mise-manageable.

ToolRoleProsConsVerdict
TFLint (accepted)Terraform linterDe-facto standard linter; bundled ruleset needs no network; mise/CI/pre-commit friendly; provider rulesets available if ever needed.Cloudflare/Neon have no provider-specific ruleset, so coverage is language-level only.Accepted.
checkov / tfsecIaC security scannersBroad policy libraries.Security-shaped: overlaps Trivy's config scan, not the hygiene gap. tfsec is folded into Trivy upstream.Rejected: duplicates ADR 047.
OPA / conftestPolicy engineArbitrary custom policies over plans.A policy language to author and maintain, not a curated ruleset; heavier than the gap warrants.Rejected: Less Is More.
HCP Terraform policies (Sentinel)Managed policyRuns where the plan runs.Paid tier; platform lock-in for a linting concern.Rejected: free tooling covers the gap.
Status quo (validate + Trivy)n/aNo new tool.Leaves the deterministic-hygiene quadrant empty.Rejected: closing that gap is the point.

Where It Sits On The Adoption Curve

Late majority. TFLint has been the default Terraform linter for most of a decade, with a stable CLI and a deep documentation/LLM corpus, squarely in the maturity band the philosophy favours for infrastructure tooling (LLM-Optimized), and a counterweight to the early-majority picks (zizmor) elsewhere in the stack.

Relation To Building Philosophy

  • Less Is More. One binary, one config file per root, one CI job; no platform, no account, no dashboard.
  • Short Feedback Loops. Findings surface at commit time via lint-staged, and the CI job is the fastest check on an infra PR because it skips Terraform init entirely.
  • LLM-Optimized. Agents author most Terraform changes here; a deterministic lint floor means the same hygiene regression fails the same way every time, rather than depending on which AI reviewer notices.
  • Build Flywheels. The same config shape drops into any future Terraform root in the monorepo.

Gaps & Risks

  • No Cloudflare/Neon provider ruleset exists, so provider-specific argument validation stays with terraform plan and review. If either provider ships a ruleset, enabling it is a two-line .tflint.hcl change.
  • Rule preset drift. recommended evolves across TFLint versions; a mise version bump can introduce new findings. Renovate makes that an explicit PR rather than a silent change (ADR 024).

Consequences

Positive

  • Fills the deterministic Terraform-hygiene quadrant with a blocking, secrets-free CI check and a pre-commit hook.
  • Gives infra/bootstrap/ its first pull-request check of any kind.
  • Free, offline-capable, mise-pinned for local/CI parity.

Negative

  • One more tool version to keep updated (delegated to Renovate).
  • Language-level rules only. Provider-specific mistakes still reach plan.