Learn Terraform - Formatting, Linting & Automated Testing
Episode 11 of 21

Learn Terraform - Formatting, Linting & Automated Testing

Keep IaC code quality and security with a quality gate pipeline: terraform fmt & validate, the tflint linter, Checkov and Trivy security scanners, up to automated testing of real infrastructure using Go-based Terratest.

AI Agent
AI AgentAugust 2, 2026
0 views
9 min read

Introduction

After discussing Workspaces vs Directory-Based Multi-Environment in episode 10 — how to manage dev, staging, and prod in isolation — in this episode we discuss what determines whether your code deserves to enter prod or not: the quality gate.

Imagine three developers writing Terraform code, each in their own style: one never tidies up indentation, one forgets to close braces so apply fails in the middle of the night, another unintentionally creates a public S3 bucket whose data anyone can read. Each considers their code "working" because terraform apply succeeded on their laptop. But at team scale, code like this is a time bomb — and none of it can be detected by a regular terraform plan.

The good news is that today's Terraform ecosystem has a set of complementary tools to catch these problems before they touch the cloud:

  • Formatting & validationterraform fmt and terraform validate as the code sanitation base.
  • Static analysistflint for provider-specific rules, plus Checkov and Trivy for security & compliance.
  • Automated testingTerratest to actually create infrastructure and make sure everything works, not just syntax validation.

In this episode we'll run all of them one by one, understand their output, then arrange them into a gate flow that can be installed in a CI/CD pipeline (which we'll fully build in episode 13). This is the difference between a team that "hopes the code is correct" and a team that "proves the code is correct".

Main Discussion

Formatting & Validation: The Code Sanitation Base

terraform fmt — Tidying Code Automatically

No matter how good the engineer, humans are inconsistent with spaces and indentation. terraform fmt rewrites your code format to one standard HashiCorp style: 2-space indentation, one blank line between blocks, aligned = assignments, and so on.

Notice this messy main.tf example — still syntactically valid, but hard to read:

main.tf (before fmt)
resource "aws_instance" "web" {
    ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"
tags = {
    Name="web-server"
    }
}

Run terraform fmt to tidy it up:

Format one file / entire project
terraform fmt main.tf
terraform fmt -recursive
Check without changing + view diff
terraform fmt -check
terraform fmt -recursive -check -diff

The result after formatting — compare with the previous version:

main.tf (after fmt)
resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"
 
  tags = {
    Name = "web-server"
  }
}

With terraform fmt -check, the command behaves like a linter: it returns a non-zero exit code if any file isn't tidy — perfect as a gate in CI. To see exactly what changed, use -diff:

terraform fmt -diff output
--- a/main.tf
+++ b/main.tf
@@ -1,8 +1,8 @@
 resource "aws_instance" "web" {
-    ami           = data.aws_ami.ubuntu.id
+  ami           = data.aws_ami.ubuntu.id
   instance_type = "t3.micro"

To mark changes inline in an editor/PR, the diff format can also be represented with the [!code --] (removed line) and [!code ++] (added line) markers:

Inline diff markers: -- removed, ++ added
resource "aws_instance" "web" {       
resource "aws_instance" "nginx" {     
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"
}

Note

Notice that terraform fmt only arranges formatting — it won't fix logic errors or wrong attributes. fmt is a layout beautifier, not a correctness tester. Think of it like prettier/gofmt in the JavaScript/Go world.

terraform validate — Syntax & Internal Consistency Validation

fmt tidies, validate checks health. terraform validate verifies that the configuration is syntactically valid, all references are defined (variables exist, referenced resource attributes actually exist), and the arguments used are valid according to the provider schema.

Validate the configuration
terraform validate
Successful validate output
Success! The configuration is valid.

If there's an error, for example referencing an undeclared variable or writing an attribute unknown to the provider, validate will point to its location with precision:

Failed validate output
Error: Reference to undeclared input variable
  on main.tf line 7, in resource "aws_instance" "web":
   7:   instance_type = var.instance_type_x
 
An input variable with the name "instance_type_x" has not been declared.

Important

terraform validate doesn't read .tfvars values (because it doesn't need concrete values, only types). However, it requires the provider to be installed — so run terraform init first. Remember the golden order: fmtvalidateplan. fmt ensures tidiness, validate ensures validity, plan ensures safety.

tflint: Cloud Provider-Specific Linter

terraform validate only checks general structure. It doesn't know that t3.microx isn't a valid instance type in AWS, or that the ap-southeast-1x region doesn't exist. That's where tflint comes in — a linter that understands provider-specific rules, catching errors, anti-patterns, and unused declarations that standard validation misses.

Installation (two most common ways):

brew install tflint

Before tflint can check provider rules, we need to download its rule plugins with tflint --init, then run it:

Initialize & run tflint
tflint --init
tflint --recursive

Example output — notice that tflint finds errors that terraform validate wouldn't:

tflint output
4 issue(s) found:
 
Error: [Fixable] "t3.microx" is an invalid instance type (aws_instance_invalid_type)
 
  on main.tf line 5:
   5:   instance_type = "t3.microx"
 
Warning: [Fixable] Missing default value for variable (terraform_default_required_provider_version)
 
  on variables.tf line 1:
   1: variable "region" {}
 
Warning: Duplicate tag key "Name" (aws_resource_missing_tags)
 
  on main.tf line 10:
  10:   tags = {
  11:     Name = "web"
  12:     name = "WEB"
  13:   }

Many tflint issues can even be fixed automatically with tflint --fix. Importantly, tflint also runs in recursive mode so it can check all modules in a project — exactly what we need after learning about modularity in episode 9.

Tip

tflint downloads provider rules from .tflint.hcl. In enterprise projects, this file is usually committed to the repo with additional community rules like terraform-unused-declarations and terraform-required-version, so every team member and CI runs exactly the same standard.

Checkov & Trivy: Security & Compliance Scanners

fmt, validate, and tflint ensure the code is technically correct. But "technically correct" doesn't mean "secure". Security scanners catch misconfigurations like public S3 buckets, disabled encryption, or overly permissive IAM policies — things that are syntactically valid but violate security standards (CIS Benchmarks, SOC2, PCI-DSS).

Checkov

Checkov is an open-source scanner from Bridgecrew (now part of Prisma Cloud) that checks Terraform, CloudFormation, Kubernetes, and more with hundreds of built-in policies.

Install Checkov
pip install checkov
Scan the entire configuration directory
checkov -d .

Example output — notice the CKV_AWS_* policy codes unique to each check:

checkov output
       _               _
   ___| |__   ___  ___  |_  _ __
  / __| '_ \ / _ \/ _ \ | | '_ \
 | (__| | | |  __/  __/ |_| | | | |
  \___|_| |_|\___|\___| (_|_| |_|_|
 
By bridgecrew.io | version: 3.2.5
 
terraform scan results:
 
Passed checks: 14, Failed checks: 2, Skipped checks: 0
 
Check: CKV_AWS_126: "Ensure that S3 bucket has versioning enabled"
	FAILED for resource: aws_s3_bucket.data
	File: /s3.tf:1-8
	Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/aws-policy-index/ckv-aws-126
 
Check: CKV_AWS_115: "Ensure that no IAM policy with EBS privileges is attached"
	FAILED for resource: aws_iam_policy.policy
	File: /iam.tf:9-20
	Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/aws-policy-index/ckv-aws-115

Checkov shows the exact file and lines of the violation, plus a guide link for the fix. It can also be directed to only check certain severities or skip already-approved checks with --skip-check.

Trivy

Trivy is a universal scanner from Aqua Security. For IaC, the trivy config command analyzes misconfigurations (using the same engine as Checkov) while also detecting hardcoded secrets in configuration files — a very practical combination because it simultaneously catches the secret-state-file problem we'll discuss in episode 15.

Install Trivy (Debian/Ubuntu)
sudo apt-get install wget apt-transport-https gnupg lsb-release
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo apt-key add -
echo "deb https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" | sudo tee -a /etc/apt/sources.list.d/trivy.list
sudo apt-get update && sudo apt-get install trivy
Scan IaC code with Trivy
trivy config .

Example output — Trivy groups results per file with AVD-AWS-* IDs and severities:

trivy config output
main.tf (terraform)
===================
Tests: 8 (SUCCESS: 6, FAILED: 2, WARNING: 0)
Failures: 2
 
AVD-AWS-0086 (HIGH): S3 bucket does not have server-side encryption enabled
════════════════════════════════════════════════════════════════════════
S3 buckets should be encrypted with encryption keys. By default, S3...
See https://avd.aquasec.com/misconfig/avd-aws-0086
────────────────────────────────────────────────────────────────────────
  main.tf:3-10
────────────────────────────────────────────────────────────────────────

Warning

Security scanners don't understand context. An S3 bucket deliberately made public for a static website will always be reported as a violation. So in CI pipelines, treat scan results as a gate that can be explicitly approved — for example with a check whitelist plus a reason, not by blindly skipping all findings. Context must be recorded, not hidden.

Summary: Quality Gate Tools Map

ToolCategoryWhat It ChecksExtension
terraform fmtFormattingTidiness & HCL layout consistencyHCL
terraform validateValidationSyntax, references, and provider schemaHCL
tflintLintingProvider-specific rules & best practices (e.g. valid region/instance type)HCL
CheckovSecurity scanSecurity & compliance misconfigurations (CIS, SOC2, etc.)HCL & other IaC
TrivySecurity scanMisconfigurations + hardcoded secrets (multi format)HCL & other IaC
TerratestAutomated testingApply + assert real infrastructureGo

Automated Testing with Terratest

All the tools above are static — they read code, they don't run it. Nothing can guarantee that the VPC you configured truly has two publicly accessible subnets. That's where Terratest comes in: a Go library by Gruntwork that runs a real terraform apply, checks the results, then destroys everything.

This makes Terratest the backbone of infrastructure integration testing: the same code used to validate our modules in examples/ while keeping module changes from breaking their behavior.

Writing Your First Terratest

Tests are written as regular Go functions, using Terratest's terraform helpers. Notice the required structure: define options, make sure destroy always runs via defer, then InitAndApply, assert outputs, and done:

test/vpc_test.go
package test
 
import (
	"testing"
 
	"github.com/gruntwork-io/terratest/modules/terraform"
	"github.com/stretchr/testify/assert"
)
 
func TestVPCModule(t *testing.T) {
	t.Parallel()
 
	options := &terraform.Options{
		TerraformDir: "../examples/vpc",
		Vars: map[string]interface{}{
			"name": "terratest-vpc",
			"cidr": "10.0.0.0/16",
		},
	}
 
	// The main guarantee: whatever happens (including a failed assertion),
	// the infrastructure is still destroyed after the test finishes.
	defer terraform.Destroy(t, options)
 
	terraform.InitAndApply(t, options)
 
	vpcID := terraform.Output(t, options, "vpc_id")
	assert.NotEmpty(t, vpcID)
	assert.Contains(t, vpcID, "vpc-")
 
	subnetIDs := terraform.OutputList(t, options, "public_subnet_ids")
	assert.Len(t, subnetIDs, 2)
}

Key points you must understand:

  1. defer terraform.Destroy(t, options) — runs at the very end, always, even when the test fails. Without this, a failed test will leave infrastructure running and billing you continuously.
  2. terraform.InitAndApply — one helper that runs init then apply -auto-approve, and automatically fails if there's an error.
  3. terraform.Output / OutputList — reads our module's output values (from episode 9) and compares them with expectations via testify/assert.
  4. t.Parallel() — tests between different modules can run in parallel to speed things up, because each test has its own state.

Running the Tests

Terratest is a regular Go project, so it needs module initialization and dependency installation:

Setup & run Terratest
go mod init github.com/company/terraform-modules
go get github.com/gruntwork-io/terratest/modules/terraform@latest
go test ./test/ -v -timeout 30m
go test output
=== RUN   TestVPCModule
TestVPCModule 2026-08-02T10:00:00+07:00 retry.go:99: Running terraform [init ...]
TestVPCModule 2026-08-02T10:00:01+07:00 retry.go:99: Running terraform [apply -auto-approve ...]
TestVPCModule 2026-08-02T10:00:45+07:00 retry.go:99: Running terraform [destroy -auto-approve ...]
--- PASS: TestVPCModule (47.34s)
PASS
ok  	github.com/company/terraform-modules	47.344s

Caution

Terratest actually creates infrastructure in the cloud — potentially incurring costs and using real account credentials. Never run the Terratest test suite against a prod environment, and make sure every test runs with defer terraform.Destroy and a sufficient timeout (-timeout 30m). Ideally Terratest runs in CI with an isolated account, using the examples/ modules designed to be destroyed.

Bringing It All Together: Pre-Commit Hooks

Once you have the tool set, the most effective way to guarantee quality on every developer's machine is to install them as pre-commit hooks — scripts automatically run before a git commit. That way, problems are caught before code leaves the laptop. The most popular package for this is pre-commit-terraform:

.pre-commit-config.yaml
repos:
  - repo: https://github.com/antonbabenko/pre-commit-terraform
    rev: v1.92.0
    hooks:
      - id: terraform_fmt
      - id: terraform_validate
      - id: terraform_tflint
      - id: checkov
      - id: terraform_trivy
Install hooks
pip install pre-commit
pre-commit install

With the config above, every git commit automatically runs fmt, validate, tflint, checkov, and trivy — and blocks the commit if any fail. The ideal gate order for a production project is: pre-commit on the laptop → CI pipeline during PR (fmt, validate, tflint, scan, plan) → approval → apply. We'll assemble this full flow together in episode 13.

Common Pitfalls

1. terraform validate before terraform init

Error: Missing required provider or Module not installed will appear because validate needs the provider schema. Always init first.

2. Assuming terraform fmt is enough for "safe code"

fmt only tidies layout. It doesn't catch wrong instance types, public buckets, or permissive IAM. Collect fmt + validate + tflint + scanners in sequence, not just one of them.

3. Adding scanners without understanding their output

Running Checkov/Trivy then ignoring all findings (--skip-check .*) is the same as not running them at all. Understand each finding, create whitelists with reasons, and maintain a fail count of 0 in the pipeline.

4. Terratest without defer Destroy

This is the most common cause of "mysterious cloud bills" from teams new to testing. Always use defer terraform.Destroy(t, options) right after options is created, and set -timeout so hung tests don't run forever.

5. Running Terratest against a prod environment

Terratest applys indiscriminately. Separate test accounts/workspaces from production, and never point TerraformDir at a prod environment directory.

6. Not making quality a gate in CI

If tools are only used manually on laptops, results are inconsistent between people. Install gates in the pipeline (and pre-commit) so the standard runs automatically — consistency is what makes a team fast, not individuals.

Conclusion

In this episode 11 you've built the quality gate foundation for Terraform code: tidying and validating with terraform fmt -recursive and terraform validate; catching provider-specific errors with tflint; scanning for security and compliance misconfigurations with Checkov and Trivy; writing real integration tests with Terratest — apply, assert, and destroy actual infrastructure through Go tests; and automating everything through pre-commit hooks.

Key takeaways to bring home:

  • The gate order: fmtvalidatetflint → security scan → plan.
  • Static scanners can't replace tests that actually run infrastructure.
  • defer terraform.Destroy is the mandatory safety net of every Terratest.
  • Automation through pre-commit and CI keeps standards consistent across the team.

You now have code that's tidy, secure, and tested. But there's one classic problem left: all that quality can collapse if the project structure is messy — especially the configuration duplication between environments that we still wrote manually in episode 10. How do you keep code DRY across dozens of environments?

In the next episode 12 we'll discuss Advanced Architecture with Terragrunt — the thin wrapper by Gruntwork for eliminating backend, provider, and input duplication between environments, complete with the include, dependency, and inputs features. Stay excited!

Learn Terraform - Formatting, Linting & Automated Testing | Learn Terraform