Skip to main content
RunBook Academy

TerraformXXI · Testing, Linting, and Static AnalysisProduction Terraform

Linting with tflint

Intermediate⏱ ~12 minbash

What you'll learn

  • Run tflint with a project ruleset and explain what each rule category catches
  • Configure a per-team .tflint.hcl that selects the right rule plugins and disables rules that fight the codebase
  • Wire tflint into CI as a blocking gate distinct from terraform validate
  • Identify the failure modes that tflint catches and the ones it deliberately does not

Prerequisites

None — start here.

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

terraform fmt checks the file is canonically styled. terraform validate checks the file is syntactically valid. Between them, they catch a wide class of errors, but they do not catch the category that causes the most production pain: the file is syntactically correct, semantically valid, and still wrong. An aws_instance with instance_type = "t2.nano" in a region that does not offer it. An aws_s3_bucket with a name that violates the global naming rules. A for_each over a set of strings that contains an empty string. tflint is the right tool for this category.

What tflint is

tflint is a third-party linter maintained by the terraform-linters community. It is not a HashiCorp project. It is not distributed with Terraform. It is installed as a separate binary and run as a separate gate.

The lint happens in three layers:

Layer 1: terraform-rules (the language rules)
  - terraform_naming_convention
  - terraform_documented_variables
  - terraform_typed_variables
  - terraform_unused_declarations
  - 30+ rules, all language-level

Layer 2: provider rules
  - aws, azurerm, google, etc.
  - Validate resource attributes against the provider schema
  - Catch typos in attribute names
  - Catch instance types that do not exist

Layer 3: your own rules
  - Custom regex rules
  - Custom Terraform plugin rules
  - Any rule you can encode

The tool is fast. A typical module lints in under a second. It does not require terraform init for the language rules and only requires init when the provider rules are enabled.

How tflint differs from terraform validate

terraform validate checks the configuration against the provider schema in the local .terraform.lock.hcl. It will catch a typo on an attribute name. So why tflint?

tflint checks the configuration against the current provider schema on the registry. It will catch:

  • An attribute deprecated in the current provider version
  • An instance type that no longer exists
  • A resource argument that is invalid in the current provider major version
  • A region-specific constraint that the provider schema does not model

terraform validate does not catch these. It validates against the schema baked into the plugin version that init downloaded. If the lock file is pinned to a stale plugin, validate will accept arguments the current provider has rejected.

tflint is therefore the “fresh schema” check. CI runs both: validate against the locked schema, tflint against the latest.

Installing tflint

Ubuntu 24.04 / Debian 12, manual install:

# Severity: CONFIGURATION - installs a binary to /usr/local/bin.
curl -fsSL https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh | bash
tflint --version

GitHub Actions:

- name: Setup tflint
  uses: terraform-linters/setup-tflint@v4
  with:
    tflint_version: latest

The binary is self-contained. No go-runtime, no plugin manager at install time.

The per-team configuration

The configuration file is .tflint.hcl at the repository root. A configuration that a mid-sized platform team would actually use:

# .tflint.hcl
plugin "terraform" {
  enabled = true
  preset  = "recommended"
}

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

rule "terraform_unused_declarations" {
  enabled = true
}

rule "terraform_naming_convention" {
  enabled = true
}

rule "terraform_documented_variables" {
  enabled = true
}

rule "terraform_documented_outputs" {
  enabled = true
}

rule "terraform_typed_variables" {
  enabled = true
}

rule "terraform_module_pinned_source" {
  enabled = true
}

# Disable rules that fight the codebase
rule "terraform_comment_syntax" {
  enabled = false
}

rule "terraform_deprecated_index" {
  enabled = false
}

The preset = "recommended" line selects the curated set of language rules. The aws plugin block turns on the AWS rules with deep_check = true, which queries the AWS APIs for current instance type availability.

Per-team customisation:

  • Brownfield. Disable the rules that would fail on pre-existing code. Enable them in CI for new code only.
  • Greenfield. Enable all recommended rules. Add the provider ruleset that matches the cloud.
  • Multi-cloud. Enable the relevant aws, azurerm, or google plugin. A repository that touches all three changes the configuration accordingly.

Wiring tflint into CI

The gate runs after fmt and validate:

name: terraform-checks
on: [pull_request]
jobs:
  lint:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: terraform-linters/setup-tflint@v4
        with:
          tflint_version: latest
      - run: tflint --init
      - run: tflint --recursive --minimum-failure-severity=warning

The --minimum-failure-severity=warning flag treats warnings as failures. The default is error, which leaves warnings visible but non-blocking. The production choice is warning for the team gate and error for the merge gate.

The --recursive flag walks the directory tree. For a monorepo with many modules, it is the right choice.

The right CI gate

tflint runs in two modes in a healthy pipeline:

  • Soft gate on PRs. Run tflint, send results to the PR as a comment. Do not block the merge. The gate is informative.
  • Hard gate on merge. Run tflint with --minimum-failure-severity=error. A failure blocks the merge.

The split exists because warnings are noisy. A pinned module version that the team has decided to grandfather should be a warning, not a block. The PR sees the warning and the contributor knows. The merge only sees errors.

Production failure modes

1. tflint plugin download fails in CI

Symptom: tflint --init fails with Failed to fetch the plugin. Cause: the CI runner has no outbound internet, or the runner’s egress is restricted to a private registry. Fix: vendor the plugins in the repo or use a private mirror. The .tflint.hcl source field can point to a private mirror.

2. tflint fails on a resource the provider does support

Symptom: tflint reports an invalid attribute, but terraform plan accepts the same configuration. Cause: the aws plugin version is pinned to a version that pre-dates the new attribute. Fix: run tflint --init to refresh the plugin cache, or bump the plugin version in .tflint.hcl.

3. tflint passes but the apply fails

Symptom: green tflint, red apply. Cause: tflint validates the schema but does not run the configuration. A value that the schema accepts but the cloud rejects will pass tflint. Fix: tflint is a lint, not a proof. The plan and apply are the next gates.

4. deep_check is slow against real AWS

Symptom: tflint with deep_check = true takes 30 seconds per module. Cause: the aws plugin queries the AWS API for current instance type availability in the configured region. Fix: disable deep_check in CI for the PR gate. Re-enable it on the nightly schedule.

Symptom: tflint reports 200 warnings on a 30-module repository. Cause: the recommended preset enables rules that the team has not yet adopted. Fix: disable the rules that are not yet enforced. Re-enable them as the codebase catches up.

6. tflint disagrees with the team’s style

Symptom: tflint says terraform_naming_convention requires snake_case for resources, the team uses kebab-case. Cause: the rule is opinionated. Fix: disable the rule or change the team. Don’t fight the tool on a stylistic preference that has no production impact.

Security and performance implications

tflint is local-only. It does not call the cloud unless deep_check is enabled, and even then the call is to the AWS public APIs. No IAM permissions are required for the language rules. The aws plugin needs read-only IAM (ec2:DescribeInstanceTypeOfferings) for deep_check.

A tflint run with the default ruleset takes milliseconds. With deep_check against AWS, it takes a few seconds per region. The CI cost is bounded by the number of modules and the number of regions.

The configuration file itself is a security artefact: it encodes the rules the team has decided to enforce. A contributor who can change .tflint.hcl can disable a rule. The right discipline is to require a code review on any change to the rule file.

Production guidance

  • Pin the tflint plugin versions in .tflint.hcl. The version of the aws plugin determines which provider schema tflint validates against.
  • Run tflint --recursive as a CI gate. The default --minimum-failure-severity=warning is the team gate; bump to error for the merge gate.
  • Vendor the plugins in CI. The --init step downloads from the public registry; CI runners should mirror the plugins internally.
  • Disable the rules that fight the codebase, but disable them in the configuration, not by skipping the lint. The configuration file is the audit trail.
  • Read the release notes for the rule plugins. The recommended preset changes between versions.

Verification

# Initialise the plugins
tflint --init

# Lint the module
tflint --recursive

# Lint with warnings as errors
tflint --recursive --minimum-failure-severity=warning

A clean tflint run exits 0. A failed rule exits with a non-zero code and the file:line of the violation.

Knowledge check · 7 questions

  1. Q1. What category of error does tflint catch that terraform validate does not?

  2. Q2. tflint is a separate third-party binary that has to be installed and run alongside Terraform.

  3. Q3. Where should the tflint configuration live?

  4. Q4. What does the `deep_check = true` option in the `aws` plugin block do?

  5. Q5. Which of the following are good per-team choices when configuring tflint? (Select all that apply.)

  6. Q6. A team wants to gate merges on tflint. Which flag achieves the strictest behaviour?

  7. Q7. A module passes tflint but fails on `terraform apply` with an error about an invalid attribute. What is the most likely cause?

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