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.

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:
terraform fmt and terraform validate as the code sanitation base.tflint for provider-specific rules, plus Checkov and Trivy for security & compliance.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".
terraform fmt — Tidying Code AutomaticallyNo 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:
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:
terraform fmt main.tf
terraform fmt -recursiveterraform fmt -check
terraform fmt -recursive -check -diffThe result after formatting — compare with the previous version:
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:
--- 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:
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 Validationfmt 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.
terraform validateSuccess! 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:
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: fmt → validate → plan. fmt ensures tidiness, validate ensures validity, plan ensures safety.
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 tflintBefore tflint can check provider rules, we need to download its rule plugins with tflint --init, then run it:
tflint --init
tflint --recursiveExample output — notice that tflint finds errors that terraform validate wouldn't:
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.
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 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.
pip install checkovcheckov -d .Example output — notice the CKV_AWS_* policy codes unique to each check:
_ _
___| |__ ___ ___ |_ _ __
/ __| '_ \ / _ \/ _ \ | | '_ \
| (__| | | | __/ __/ |_| | | | |
\___|_| |_|\___|\___| (_|_| |_|_|
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-115Checkov 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 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.
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 trivytrivy config .Example output — Trivy groups results per file with AVD-AWS-* IDs and severities:
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.
| Tool | Category | What It Checks | Extension |
|---|---|---|---|
terraform fmt | Formatting | Tidiness & HCL layout consistency | HCL |
terraform validate | Validation | Syntax, references, and provider schema | HCL |
tflint | Linting | Provider-specific rules & best practices (e.g. valid region/instance type) | HCL |
| Checkov | Security scan | Security & compliance misconfigurations (CIS, SOC2, etc.) | HCL & other IaC |
| Trivy | Security scan | Misconfigurations + hardcoded secrets (multi format) | HCL & other IaC |
| Terratest | Automated testing | Apply + assert real infrastructure | Go |
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.
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:
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:
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.terraform.InitAndApply — one helper that runs init then apply -auto-approve, and automatically fails if there's an error.terraform.Output / OutputList — reads our module's output values (from episode 9) and compares them with expectations via testify/assert.t.Parallel() — tests between different modules can run in parallel to speed things up, because each test has its own state.Terratest is a regular Go project, so it needs module initialization and dependency installation:
go mod init github.com/company/terraform-modules
go get github.com/gruntwork-io/terratest/modules/terraform@latest
go test ./test/ -v -timeout 30m=== 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.344sCaution
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.
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:
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_trivypip install pre-commit
pre-commit installWith 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.
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.
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:
fmt → validate → tflint → security scan → plan.defer terraform.Destroy is the mandatory safety net of every Terratest.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!