Skip to main content
RunBook Academy

Git, CI/CD & GitOpsL · Terraform CILint

tflint and fmt deep — the .tflint.hcl ruleset and what fmt should have caught

Intermediate⏱ ~26 mingitterraform

What you'll learn

  • Configure a .tflint.hcl file with a provider plugin and a ruleset
  • Run tflint --init to download the ruleset and explain why this is a separate step
  • Run tflint --recursive across a monorepo and interpret the output
  • Identify the gaps between terraform fmt and tflint that the ruleset closes

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.

terraform fmt enforces the HashiCorp canonical style: whitespace, indentation, argument alignment. terraform validate enforces HCL syntax and downloaded provider schemas. Both are deliberately narrow. The team-specific, opinionated, semantic layer in between - the layer that catches unused variables, deprecated attributes, missing mandatory tags, and hardcoded regions - lives in tflint and its ruleset configuration. Lesson L-02 covered the built-ins; this lesson covers the configurable layer.

Why tflint exists alongside fmt and validate

The canonical fmt style is a HashiCorp convention, not a vehicle for team policy. A team that requires all aws_instance resources to carry a tags map, that forbids hardcoded region = "us-east-1" literals, that wants a warning when an attribute has been removed in the latest version of the AWS provider - those rules are not in fmt. They are not in validate either, because the provider schema still describes the old attribute as valid until the team upgrades. The team’s rule belongs in tflint.

flowchart LR
    A[HCL source] --> B[fmt - canonical style]
    B --> C[validate - HCL + provider schema]
    C --> D[tflint - team policy and deprecations]
    D --> E[Security scanners]
    E --> F[Plan]

Each layer is a filter. Each filter catches mistakes the next layer would have wasted time on. tflint sits between validate and the security scanners because the mistakes it catches are about intent (the engineer meant something the linter can verify) rather than about safety (the security scanners handle that).

The .tflint.hcl configuration

tflint is configured by a .tflint.hcl file in the working directory. The minimum configuration that does anything useful:

plugin "terraform" {
  enabled = true
  preset  = "recommended"
}

rule "terraform_unused_declarations" {
  enabled = true
}

rule "terraform_deprecated_index" {
  enabled = true
}

rule "terraform_naming_convention" {
  enabled = true
}

For cloud-specific rules, a provider plugin is added (here, the AWS ruleset pinned to an exact version):

plugin "aws" {
  enabled = true
  version = "0.30.0"
  source  = "github.com/terraform-linters/tflint-ruleset-aws"
}

The plugin block is what makes tflint understand that aws_instance has a real schema and that the tags attribute is mandatory in some teams’ conventions. Without the plugin, tflint falls back to Terraform-level rules only.

tflint —init

Ruleset plugins are not bundled with tflint. They are downloaded on demand by tflint --init, which reads .tflint.hcl, resolves the version constraints, and installs the matching plugins into a local cache. The command that belongs in CI:

tflint --init

followed by the actual scan:

tflint --recursive
  • --init populates the plugin cache; without it, the next tflint invocation fails with “plugin not found”.
  • --recursive walks every Terraform module under the working directory. In a monorepo, the failure is reported per-module, which makes the diff in the PR comment actionable.

The two commands are usually paired: init on cache miss, then a recursive run. In CI, a fresh runner always needs init first.

What tflint catches that fmt and validate miss

The categories of finding tflint produces and the built-ins do not:

  • Unused declarations - variables, locals, outputs, and resources declared but never referenced. fmt cannot notice because the reference graph is semantic.
  • Deprecated attributes - attributes the provider schema still accepts but the documentation marks as deprecated. validate accepts them; tflint warns.
  • Naming convention violations - a regex enforced per resource type. Teams use this to keep aws_* resource names aligned with their tagging policies.
  • Provider-specific rules - rules that only make sense for one cloud (mandatory tags on AWS, mandatory labels on GCP, mandatory resource group on Azure). The plugin is the ruleset; without it, the rule does not exist.

What deep formatting means

The phrase “fmt deep” in the lesson title refers to the formatting-style rules that canonical terraform fmt deliberately does not enforce, but that teams routinely want. Examples:

  • Two blank lines between top-level blocks, not one.
  • Sorted tags keys for deterministic diffs.
  • Consistent string quoting (always double, or always single, no mixing).
  • Aligned = signs across blocks of the same kind.
  • Trailing comma in multi-line arguments (or, more often, no trailing comma).

These are not enforceable by terraform fmt because the HashiCorp style is intentionally permissive. The two options for enforcing them are: an editor integration that applies them on save (unverifiable in CI), or a tflint rule that fails the build when the convention is broken. The latter is the production choice because the CI is the only enforcement point that every change goes through.

Production discipline

  1. .tflint.hcl is committed to the repository. It is part of the change policy, not a developer preference.
  2. Plugin versions are pinned exactly. Wildcards turn the linter into a moving target.
  3. tflint --init runs before tflint --recursive on a fresh runner. Cache miss is the most common cause of CI flake.
  4. New rules start as warnings, then escalate to errors after a grace period. A rule that fails on day one teaches engineers to disable the linter.

Cross-course references

  • Terraform for Production Sysadmins - Part VI (Modules) covers module source pinning, which tflint enforces.
  • This course, Part XLIX (InfrastructureCI) - lesson git-cicd-gitops-xlix-03-lint-and-static-analysis is the general framing of the lint stage; this lesson is the Terraform-specific instantiation.
  • This course, Part XLIX (InfrastructureCI) - lesson git-cicd-gitops-xlix-02-format-stage covered the format gate that tflint extends.
  • Ansible for Production Sysadmins - Part XXII (AnsibleLint) is the analogous linter for Ansible, with the same gap-closing role.

Quiz

Knowledge check · 4 questions

  1. Q1. A team wants to enforce that every aws_instance resource carries a tags map. Which gate is the right place to encode this rule?

  2. Q2. tflint can be run in CI without first running tflint --init if the .terraform directory is already populated.

  3. Q3. Name three classes of mistake tflint catches that terraform fmt and terraform validate do not catch, and explain why neither built-in can catch them.

  4. Q4. Diagnose a tflint flake caused by a missing init step, and propose the fix.

    A team adopts tflint with the AWS ruleset. The first CI run fails with `plugin 'aws' not found in the plugin cache`. The team runs `tflint` locally and it works, because the developer previously ran `tflint --init` on their laptop weeks ago. The CI runner is a fresh container with no cache.

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