Testing & CI for Terraform
terraform fmt/validate/plan in CI, tflint and Checkov, and Terraform’s built-in test framework.
Why Terraform needs a CI pipeline too
Application code gets linted, tested, and reviewed in CI before it's merged. Infrastructure code changes real cloud resources — often ones costing real money or serving real traffic — and deserves at least the same discipline. This page covers the checks worth running automatically on every change: formatting, validation, a plan for human review, and dedicated tools that catch security and style issues Terraform itself doesn't know to look for.
The core checks: fmt, validate, plan
Three built-in commands form the baseline of any Terraform CI pipeline, each catching a different class of problem:
# Checks formatting only — fails if any file isn't canonically formatted,
# without actually rewriting anything (add -write=false is implicit with -check)
terraform fmt -check -recursive
# Checks internal consistency: valid HCL syntax, correct argument names/types,
# variables referenced actually exist — all WITHOUT talking to any cloud API
# or needing real credentials.
terraform validate
# Computes what would actually change against real infrastructure — this
# DOES need real provider credentials, since it reads current cloud state.
terraform plan -out=tfplan
fmt -check and validate are cheap, fast, and need no cloud credentials at all — they should run on every single push, including from a fork with no access to secrets. plan is the one genuinely useful, and genuinely more expensive, check: it tells reviewers exactly what would change, but only works with real provider credentials configured in the CI environment.
A GitHub Actions workflow
A realistic pipeline: run the cheap checks and a plan on every pull request, and only run apply after a merge to main:
# .github/workflows/terraform.yml
name: Terraform
on:
pull_request:
paths: ["infra/**"]
push:
branches: [main]
paths: ["infra/**"]
jobs:
plan:
runs-on: ubuntu-latest
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.9.0"
- name: Format check
run: terraform fmt -check -recursive
- name: Init
run: terraform init
- name: Validate
run: terraform validate
- name: Plan
run: terraform plan -no-color -out=tfplan
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
apply:
needs: plan
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.9.0"
- run: terraform init
- run: terraform apply -auto-approve
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
The apply job's if: github.ref == 'refs/heads/main' condition is what keeps applies restricted to the merged, reviewed state of the code — a pull request only ever gets as far as plan, giving reviewers the diff without letting a not-yet-approved branch touch real infrastructure. Posting that plan's output as a comment on the pull request (several off-the-shelf GitHub Actions do exactly this) turns it into something a teammate actually reads during review, the same way they'd read an application code diff.
Linting with tflint
terraform validate only checks that configuration is internally consistent — it has no opinion on whether an instance type is a real, valid one for a given cloud, or whether a variable is declared but never used. tflint fills that gap with provider-aware rules:
tflint --init # downloads the AWS (or other provider) rule plugin
tflint
2 issue(s) found:
Warning: instance_type is invalid (t3.mega is not a valid instance type) (aws_instance_invalid_type)
on main.tf line 12:
12: instance_type = "t3.mega"
Warning: variable "unused_region" is declared but not used (terraform_unused_declarations)
on variables.tf line 5:
5: variable "unused_region" {
This catches typos and dead configuration well before they'd otherwise surface as a confusing provider API error during apply.
Security and compliance scanning with Checkov
Checkov scans Terraform configuration for known-risky patterns — the infrastructure equivalent of a static analysis security tool — without needing to apply anything:
checkov -d infra/
Check: CKV_AWS_18: "Ensure the S3 bucket has access logging configured"
FAILED for resource: aws_s3_bucket.app_backups
File: /main.tf:20-27
Check: CKV_AWS_21: "Ensure the S3 bucket has versioning enabled"
PASSED for resource: aws_s3_bucket.app_backups
It ships with hundreds of built-in rules across every major cloud provider (public S3 buckets, security groups open to 0.0.0.0/0, unencrypted storage, overly broad IAM policies) and can be added as its own CI step, failing the pipeline on findings above a chosen severity — catching a genuinely dangerous misconfiguration (like a database security group accidentally open to the entire internet) before it's ever applied, rather than during an after-the-fact security review.
Terraform's own test framework
Since Terraform 1.6, .tftest.hcl files let you write assertions against a configuration's plan or apply output directly, without any third-party tool:
# tests/static_site.tftest.hcl
run "creates_bucket_with_expected_name" {
command = plan
variables {
bucket_name = "myapp-test-bucket"
}
assert {
condition = aws_s3_bucket.this.bucket == "myapp-test-bucket"
error_message = "Bucket name did not match the input variable"
}
}
terraform test
This is newer and less universally adopted than fmt/validate/plan or tflint/Checkov, but it's the right tool specifically for asserting a module behaves correctly across different input combinations — genuinely useful once a module (see modules in depth) is shared widely enough that regressions in it are expensive.
Putting the pieces together
| Tool | Checks | Needs cloud credentials? | Typical stage |
|---|---|---|---|
terraform fmt -check |
Canonical formatting | No | Every push |
terraform validate |
Internal syntax/type consistency | No | Every push |
tflint |
Provider-aware rules, unused variables | No | Every push |
checkov |
Security/compliance misconfigurations | No | Every push |
terraform plan |
What would actually change | Yes | Pull requests |
terraform test |
Behavioral assertions on modules | Depends | Module changes |
terraform apply |
Actually changes infrastructure | Yes | After merge to main only |
Common mistakes
- Running
terraform applydirectly from a pull request branch instead of gating it behind a merge tomain— this defeats the entire purpose of code review for infrastructure changes. - Treating
terraform validateas sufficient on its own — it catches syntax and type errors but has no idea whethert3.megais a real instance type or whether a security group is dangerously permissive; that's what tflint and Checkov are for. - Giving a CI pipeline's plan job the same broad cloud credentials as the apply job — a
planonly needs read access to compute a diff, and scoping its credentials down limits the blast radius if the pipeline itself is ever compromised. - Not posting
planoutput anywhere a human reviewer will actually see it before approving — a plan that only exists in CI logs nobody reads provides none of the reviewability it's meant to. - Adding Checkov or tflint and immediately failing the build on every pre-existing finding in a large legacy codebase — a staged rollout (warn first, enforce later, on new code only) avoids blocking every unrelated PR on day one.