TerraformXXI · Testing, Linting, and Static AnalysisProduction Terraform
Linting with tflint
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
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, orgoogleplugin. 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.
5. The recommended preset is too noisy
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 theawsplugin determines which provider schema tflint validates against. - Run
tflint --recursiveas a CI gate. The default--minimum-failure-severity=warningis the team gate; bump toerrorfor the merge gate. - Vendor the plugins in CI. The
--initstep 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
recommendedpreset 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
Q1. What category of error does tflint catch that terraform validate does not?
Q2. tflint is a separate third-party binary that has to be installed and run alongside Terraform.
Q3. Where should the tflint configuration live?
Q4. What does the `deep_check = true` option in the `aws` plugin block do?
Q5. Which of the following are good per-team choices when configuring tflint? (Select all that apply.)
Q6. A team wants to gate merges on tflint. Which flag achieves the strictest behaviour?
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.