# ADR 051: TFLint (Terraform Linting)

- HTML version: https://robbiepalmer.me/projects/personal-site/adrs/051-tflint
- Project: Personal Site (https://robbiepalmer.me/projects/personal-site.md)
- Status: Accepted
- Date: 2026-07-20

# Context

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

* **`terraform fmt` + `terraform validate`** ([ADR 010](/projects/personal-site/adrs/010-terraform))
  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](/projects/personal-site/adrs/047-trivy))
  looks for *security* misconfigurations — exposed resources, excessive
  permissions — not correctness or hygiene.

Mapping this onto the determinism axis from
[ADR 048 (SonarQube)](/projects/personal-site/adrs/048-sonarqube), the way
[ADR 049 (zizmor)](/projects/personal-site/adrs/049-zizmor) did for workflows:

|                       | Terraform security | Terraform correctness/hygiene          |
| --------------------- | ------------------ | -------------------------------------- |
| **Deterministic**     | Trivy              | `validate` only (permissive) — **gap** |
| **Non-deterministic** | AI reviewers       | AI 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](/projects/personal-site/adrs/004-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: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/` 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](/projects/personal-site/adrs/047-trivy)
and [ADR 049](/projects/personal-site/adrs/049-zizmor): deterministic, free,
no new platform, mise-manageable.

| Tool                                  | Role                  | Pros                                                                                                                                 | Cons                                                                                                     | Verdict                                                          |
| ------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| **TFLint** *(accepted)*               | Terraform linter      | De-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 / tfsec**                   | IaC security scanners | Broad policy libraries.                                                                                                              | Security-shaped: overlaps Trivy's config scan, not the hygiene gap. tfsec is folded into Trivy upstream. | Rejected: duplicates ADR 047.                                    |
| **OPA / conftest**                    | Policy engine         | Arbitrary custom policies over plans.                                                                                                | A policy *language* to author and maintain, not a curated ruleset; heavier than the gap warrants.        | Rejected: [Less Is More](/projects?tab=philosophy#less-is-more). |
| **HCP Terraform policies (Sentinel)** | Managed policy        | Runs where the plan runs.                                                                                                            | Paid tier; platform lock-in for a linting concern.                                                       | Rejected: free tooling covers the gap.                           |
| **Status quo** (`validate` + Trivy)   | n/a                   | No 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](/projects?tab=philosophy#llm-optimized)), and a counterweight
to the early-majority picks (zizmor) elsewhere in the stack.

# Relation To Building Philosophy

* [Less Is More](/projects?tab=philosophy#less-is-more). One binary, one config
  file per root, one CI job; no platform, no account, no dashboard.
* [Short Feedback Loops](/projects?tab=philosophy#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](/projects?tab=philosophy#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](/projects?tab=philosophy#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](/projects/personal-site/adrs/024-renovate)).

# 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`.

---

Markdown index of this site: https://robbiepalmer.me/llms.txt
