The problem: reviewers wasting time on things a machine can check
A teammate opens a pull request. The reviewer spends ten minutes pointing out bad indentation, a variable nobody uses, a typo in a VM size, a module pinned to a moving branch - and misses that the change deletes a database. Every one of those small things could have been caught by a tool, in seconds, before a human ever looked.
This lesson is those tools. They run on every push, need no Azure login, and free the reviewer to think about what the change does.
What you need to know already: exit codes and $? (1.7), set -euo pipefail (6.1), terraform init / validate / plan (12.24), provider version constraints (12.3), module sources with ?ref= (13.38).
Three layers of "is this code any good"
terraform fmt -check style: exactly one canonical layout exit 3 if not
terraform validate language: syntax, references, types, arguments exit 1 on errors
tflint judgement: unused code, missing pins, values exit 2 on issues
the provider would reject at apply time
- fmt (format) only cares how the code looks.
- validate checks the code is correct Terraform.
- tflint is a linter: a tool that flags code that is valid but probably wrong or sloppy.
Each catches things the one before cannot. All three run in seconds and need no cloud credentials, so they go at the very start of every pipeline and in a pre-commit hook (both explained below).
The exit codes matter: a script with set -e, or a pipeline, stops at the first command that exits non-zero. So "exit 3" from fmt is what makes the pipeline go red.
fmt
terraform fmt rewrites .tf files into the one standard layout: two-space indentation, = signs lined up, consistent spacing. It never changes what the code means.
# in the Build mission's repo, with two badly formatted files
terraform fmt -check -recursive
modules/aks/aks.tf
envs/prod/main.tf
echo $?
3
terraform fmt -recursive
modules/aks/aks.tf
envs/prod/main.tf
What happened: the first command only checked, listed the two files that are not formatted, and exited 3. The second command (no -check) fixed them and printed the files it changed.
| flag | means |
|---|---|
-check | do not change anything; list unformatted files and exit 3 if there are any |
-recursive | also go into subfolders - without it, modules/ and envs/ are skipped |
-diff | show the changes it would make, as a diff |
In CI you run terraform fmt -check -recursive (fail if anything is off). On your laptop you run terraform fmt -recursive (just fix it).
validate
$ terraform validate
Success! The configuration is valid.
terraform validate checks the code against the provider schemas - the provider's list of which resources exist, which arguments they take and what type each argument is. So it catches typos in argument names, wrong types, and references to variables or resources that do not exist.
It needs terraform init first, because init downloads the providers and their schemas. In CI you do not want the checks to need access to the state storage, so you use:
terraform init -backend=false -input=false
-backend=false installs providers and modules but does not connect to the backend. -input=false means "never stop and ask a question; fail instead" - right for anything that runs unattended.
What validate does not do: it does not read tfvars (so variable values are unknown), does not call Azure, and does not know whether Standard_D4sv5 is a real VM size - to validate, it is just a string.
tflint
tflint is a separate tool (terraform-linters/tflint on GitHub) with pluggable rulesets - bundles of rules. The terraform ruleset is built in. Cloud-specific rulesets such as azurerm are plugins you install.
$ tflint
3 issue(s) found:
Warning: variable "legacy_sku" is declared but not used (terraform_unused_declarations)
on variables.tf line 18:
18: variable "legacy_sku" {
Reference: https://github.com/terraform-linters/tflint-ruleset-terraform/blob/v0.9.1/docs/rules/terraform_unused_declarations.md
Warning: Missing version constraint for provider "random" in `required_providers` (terraform_required_providers)
on main.tf line 21:
21: resource "random_string" "kv" {
Reference: https://github.com/terraform-linters/tflint-ruleset-terraform/blob/v0.9.1/docs/rules/terraform_required_providers.md
Warning: Module source "git::https://github.com/acme/tf-modules.git//aks?ref=main" uses a default branch as ref (main) (terraform_module_pinned_source)
on envs/dev/main.tf line 3:
3: source = "git::https://github.com/acme/tf-modules.git//aks?ref=main"
How to read one issue:
Warning:- the severity (Error,WarningorNotice).- The message, then the rule name in brackets, e.g.
(terraform_unused_declarations). The rule name tells you what to fix. on variables.tf line 18:and the line itself - where.Reference:- a link to that rule's documentation.
Here: a variable nobody uses (delete it), a provider with no version constraint (add one), and a module pinned to main, a branch that moves (pin a tag like v1.1.0 instead, 13.40).
Exit status: 0 no issues, 1 tflint itself failed (e.g. a bad config), 2 issues found. --force always exits 0; --minimum-failure-severity=error fails only on errors, not warnings.
The recommended rules
With no configuration, the terraform ruleset runs its recommended preset (a preset is a named set of rules switched on together):
terraform_unused_declarations variables, locals, data sources nobody uses
terraform_typed_variables variables without a type
terraform_required_version no required_version in the terraform block
terraform_required_providers a provider used without a version constraint
terraform_module_pinned_source git module without ?ref=, or ref = main/master
terraform_module_version registry module without version
terraform_deprecated_interpolation "${var.x}" alone (0.11 style)
terraform_deprecated_index list.0 instead of list[0]
terraform_deprecated_lookup lookup() with two arguments
terraform_empty_list_equality == [] comparisons
terraform_map_duplicate_keys the same key twice in a map literal
terraform_workspace_remote terraform.workspace with remote execution (TF 1.0)
terraform_deprecated_interpolation is the one that surprises people: writing "${azurerm_resource_group.jump.name}" (a reference wrapped in a string) is old Terraform 0.11 style. Write the reference bare: azurerm_resource_group.jump.name.
Rules that are off by default but worth turning on: terraform_naming_convention (names in snake_case, like aks_subnet), terraform_documented_variables / terraform_documented_outputs (every variable/output has a description), terraform_standard_module_structure (the usual main.tf / variables.tf / outputs.tf files).
.tflint.hcl: the configuration file
tflint reads .tflint.hcl from the folder you run it in. It is HCL, like Terraform code:
plugin "terraform" {
enabled = true
preset = "recommended"
}
plugin "azurerm" {
enabled = true
version = "0.27.0"
source = "github.com/terraform-linters/tflint-ruleset-azurerm"
}
rule "terraform_naming_convention" {
enabled = true
}
rule "terraform_documented_variables" {
enabled = true
}
plugin "terraform"- the built-in ruleset, with the recommended preset.plugin "azurerm"- the Azure ruleset: whichversionof the plugin, and where to download it from (source). Pin the version, like any dependency.rule "..." { enabled = true }- switch on one extra rule.
A plugin must be installed before it can run. tflint --init reads .tflint.hcl and downloads the plugins it lists:
# in a repo whose .tflint.hcl enables the azurerm plugin, before tflint --init has run
tflint
Failed to initialize plugins; Plugin "azurerm" not found. Did you run "tflint --init"?
tflint --init
Installing "azurerm" plugin...
Installed "azurerm" (source: github.com/terraform-linters/tflint-ruleset-azurerm, version: 0.27.0)
What the azurerm ruleset adds
The ruleset knows the allowed values of many "pick one of a list" arguments - VM sizes, replication types, SKUs (Azure's word for a product tier or size) - and flags values Azure would reject:
Error: "Standard_D4sv5" is an invalid value as size (azurerm_linux_virtual_machine_invalid_size)
on main.tf line 30:
30: size = "Standard_D4sv5"
(The real name is Standard_D4s_v5 - one missing underscore.)
terraform validate passes that configuration: size is a valid argument and the value is a string. terraform plan passes it too. The failure would come ten minutes into apply, from Azure itself - possibly after other resources were already created. tflint moves it to the first second of the pipeline. (The lab's ruleset knows a subset of the real value lists - simulator.)
Useful flags
tflint --recursive every directory with .tf files below here
tflint --format=compact file:line:col: Severity - message (rule) - for CI logs
tflint --format=json for tooling
tflint --chdir=envs/prod as if started there
tflint --minimum-failure-severity=warning
--recursive- lint every folder, including each module on its own. Without it, only the current folder is linted.--format=compact- one line per issue, easy to read in a CI log.--format=json- machine-readable, for other tools (andjq, 7.11).--chdir=envs/prod- run as if you hadcd'd there first.--minimum-failure-severity=warning- exit 2 on warnings and errors, not on notices.
Wiring it together
The whole set, as a script a pipeline (or you) runs:
#!/usr/bin/env bash
set -euo pipefail
terraform fmt -check -recursive
terraform init -backend=false -input=false
terraform validate
tflint --init
tflint --recursive --format=compact
The order is cheapest first: fmt needs nothing, validate needs providers, tflint needs its plugins. set -euo pipefail (6.1) stops at the first failure, so the output always ends at the first real problem.
Every mistake caught here is a mistake that never reaches a plan reviewer - who should be spending attention on what the change does, not on indentation.
Worked example: one failing run, three fixes
# the quality mission's ci/check.sh, on a branch with three problems
./ci/check.sh
== fmt
modules/storage/main.tf
main.tf
echo $?
3
terraform fmt -recursive
modules/storage/main.tf
main.tf
./ci/check.sh
== fmt
== validate
╷
│ Error: Reference to undeclared input variable
│
│ on modules/storage/main.tf line 14, in resource "azurerm_storage_account" "this":
│ 14: resource_group_name = var.resource_group
│
│ An input variable with the name "resource_group" has not been declared. This
│ variable can be declared with a variable "resource_group" {} block.
╵
Step by step:
- First run: fmt listed two files and the script stopped with exit 3.
terraform fmt -recursivefixed them.- Second run: fmt passed (
== fmtwith nothing under it), validate failed. The module usesvar.resource_groupbut never declares avariable "resource_group"block. That is a real bug, not style.
After declaring the variable in the module (and passing it from the root), the third run gets to tflint:
== tflint
2 issue(s) found:
Warning: terraform "required_version" attribute is required (terraform_required_version)
Warning: Missing version constraint for provider "azurerm" in `required_providers` (terraform_required_providers)
The module does not say which Terraform and which provider version it needs. A module states a minimum (required_version = ">= 1.9", azurerm ">= 4.0"); lesson 14.23 explains why modules use minimums and roots use tight pins.
pre-commit: the same checks before every commit
A git hook is a script git runs automatically at a certain moment - a pre-commit hook runs just before each git commit, and can stop the commit if a check fails. pre-commit is a popular tool that manages such hooks from a config file. The widely used Terraform hook collection is antonbabenko/pre-commit-terraform:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.96.1 # pin a release tag
hooks:
- id: terraform_fmt
- id: terraform_validate
- id: terraform_tflint
- id: terraform_checkov
(terraform_checkov runs the security scanner from the next lesson.) Setting it up:
pip install pre-commit # or brew install pre-commit
pre-commit install # adds the hook to the repository
pre-commit run --all-files # run everything once
pip install / brew install install the tool itself (Python's and macOS's package managers). pre-commit install adds the hook to this repository's .git/ folder. pre-commit run --all-files runs every check on every file once.
Hooks only check the files you changed, so they stay fast. CI still runs the full set, because a hook can be skipped (git commit --no-verify) or simply not installed on someone's laptop.
What each tool cannot see
fmt meaning of anything
validate variable values (tfvars are not read), provider-side rules, the cloud
tflint real resources and state; only what its rules know (the azurerm ruleset
knows valid VM sizes, not whether your subscription has quota for one)
plan everything above - but only by talking to the cloud, and slowly
("Quota" is Azure's limit on how much of something your account may create.)
The cheap tools do not replace the plan; they make sure the plan reviewer only sees changes worth thinking about.
What you can now do:
- Run
terraform fmt -check -recursive,terraform validateandtflint --recursive, and read each tool's output and exit code. - Configure tflint with
.tflint.hcland install plugins withtflint --init. - Chain the checks in a script that stops at the first failure.