Git, CI/CD & GitOpsL · Terraform CILint
tflint and fmt deep — the .tflint.hcl ruleset and what fmt should have caught
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
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
--initpopulates the plugin cache; without it, the nexttflintinvocation fails with “plugin not found”.--recursivewalks 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
tagskeys 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
.tflint.hclis committed to the repository. It is part of the change policy, not a developer preference.- Plugin versions are pinned exactly. Wildcards turn the linter into a moving target.
tflint --initruns beforetflint --recursiveon a fresh runner. Cache miss is the most common cause of CI flake.- 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-analysisis 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-stagecovered 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
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?
Q2. tflint can be run in CI without first running tflint --init if the .terraform directory is already populated.
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.
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.