Skip to main content
RunBook Academy

Git, CI/CD & GitOpsCIX · Terraform Delivery PipelineLintLayer

tflint and fmt-deep in CI — the lint layer

Advanced⏱ ~25 mingitterraformtflint

What you'll learn

  • Run tflint --init and tflint --recursive as the lint stage of a Terraform CI pipeline
  • Distinguish tflint from terraform validate: provider-aware rules, deep-format, deprecations
  • Configure tflint with a pinned .tflint.hcl so CI and local runs agree on rules
  • Identify the placement of the lint stage between validate and security scanning

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 third stage of a production Terraform pipeline is the lint layer. It runs after terraform validate (which checks HCL syntax and provider schemas) and before the security scanners (which check policy). Its job is to catch the class of mistakes validate was never designed to catch: provider-specific deprecations, naming-convention violations, and rules the team has codified about how resources must be shaped.

Why a linter is needed

terraform validate is a semantic checker, not a linter. It ensures the configuration parses and that the downloaded provider schemas accept the attributes used. It does not enforce:

  • Naming conventions. A variable named cidr_block in one module and vpc_cidr in another is a refactoring hazard, not a validate error.
  • Provider-specific deprecations. A resource attribute that has been deprecated by the upstream provider still parses; validate accepts it. The next minor upgrade may remove it.
  • Resource-shape rules. A team rule that every aws_s3_bucket must have versioning.enabled = true is a policy decision, not an HCL fact.
  • Deep formatting. terraform fmt -check walks the working tree but stops at the top-level module. A nested module with unaligned equals signs passes a top-level fmt check.

The lint stage addresses all four. The two tools used at this layer are terraform fmt (in its deep mode) and tflint.

The fmt-deep check

The deep-format check is the same terraform fmt -check -recursive from lesson git-cicd-gitops-cix-02, applied with explicit recursion across nested modules. The command:

terraform fmt -check -recursive

The -recursive flag walks every .tf file in the working tree, including those inside module subdirectories. This catches the formatting drift that a top-level fmt check would miss in a monorepo with deeply nested module directories.

The tflint stage

tflint is a Terraform linter with a plugin system that pulls in provider-specific rule sets. It runs after terraform init -backend=false and produces a per-rule exit code that distinguishes errors from warnings.

The commands that belong in CI:

tflint --init
tflint --recursive
  • tflint --init downloads the plugins declared in .tflint.hcl — for example, the AWS, Azure, or Google plugin depending on the providers in use. The init is fast (it caches plugins in the runner’s plugin directory) and is the only network step in the lint stage.
  • tflint --recursive walks every module under the working tree and applies the configured rules. It exits non-zero when any rule is violated.
flowchart LR
    A["Working tree HCL"] --> B["tflint --init"]
    B --> C["Download plugins from .tflint.hcl"]
    C --> D["tflint --recursive"]
    D --> E{"Any rule violated?"}
    E -->|"yes"| F["Print findings, exit non-zero"]
    E -->|"no"| G["Pass, exit 0"]
    F --> H["Author fixes locally"]
    H --> A

Configuring tflint

tflint is configured by a .tflint.hcl file at the repository root. The configuration pins the plugin set, declares enabled rules, and sets per-rule severity. A minimal example for an AWS-only repository:

plugin "terraform" {
  enabled = true
  version = "0.21.0"
}

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

rule "terraform_naming_convention" {
  enabled = true
  format  = "snake_case"
}

rule "terraform_deprecated_index" {
  enabled = true
}

rule "aws_instance_invalid_type" {
  enabled = true
}

The configuration file is committed to the repository. CI and local runs read the same file, so the rules the developer sees locally are exactly the rules the pipeline enforces. A pipeline that uses an unpinned or locally-overridden configuration has no lint contract.

Placement in the pipeline

The lint stage sits between validate and the security scanners. The ordering is deliberate:

  1. fmt -check -recursive -diff — formatting, no providers needed.
  2. terraform init -backend=false — providers and modules, no state.
  3. terraform validate -json — HCL semantics against downloaded schemas.
  4. tflint --init && tflint --recursive — provider-aware rules and conventions.
  5. … security scanning, plan, policy, approval, apply.

Placing tflint after validate means the lint job does not duplicate semantic checks. Placing it before the security scanners means a deprecated attribute is caught by the linter before the security scanner is asked to evaluate a configuration that may itself be invalid. The cost of a tflint rejection is roughly the time to download plugins plus a few seconds of rule evaluation — significantly cheaper than the security scan that would otherwise run on the same invalid configuration.

Production discipline

  1. .tflint.hcl is committed, version-pinned, and reviewed like any other configuration file. The plugin versions and rule list are part of the team’s contract.
  2. tflint --recursive walks every module. A repository that lints only the root module lints only the root module.
  3. tflint warnings and errors are surfaced separately. Errors fail the build; warnings accumulate as a backlog and can be promoted to errors by a deliberate change to .tflint.hcl.
  4. The lint stage runs after init -backend=false. The lint job holds no cloud credentials and no state credentials.
  5. Local development uses the same .tflint.hcl as CI. A pre-commit hook that runs tflint --recursive on staged files catches violations before the push, not after.

Cross-course references

  • Terraform for Production Sysadmins - Part VIII (Modules) covers the module layouts tflint —recursive walks.
  • This course, Part L (TerraformCI) - lessons git-cicd-gitops-l-03 covers tflint at the intermediate level.
  • This course, Part CI (PreCommitHooks) - the pre-commit hook pattern that catches lint failures before CI.

Quiz

Knowledge check · 4 questions

  1. Q1. What is the primary difference between `terraform validate` and `tflint --recursive`?

  2. Q2. An unpinned `plugin` block in `.tflint.hcl` is acceptable in CI as long as the configuration is checked into version control.

  3. Q3. Name the two commands used in the tflint CI stage and the role of each.

  4. Q4. Diagnose why a deprecated-attribute warning reached production despite validate passing, and prescribe the lint fix.

    A team runs `terraform fmt`, `terraform init -backend=false`, and `terraform validate -json` as the only Terraform CI stages. A contributor upgrades the AWS provider from 4.x to 5.x in `.terraform.lock.hcl` but does not notice that an `aws_lb` resource uses the deprecated `access_logs.enabled` block. validate passes because the schema still accepts the attribute in 5.x with a deprecation warning. The PR is merged and applied. Months later, the team upgrades to provider 6.x and the apply fails.

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