Terraform Cheatsheet
Testing and Validation
Use this Terraform reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
The Validation Ladder
Terraform gives you five layers, cheapest first. Use them in this order, because each one catches a class of bug the next one cannot.
| Layer | Runs at | Fails what | Cost |
|---|---|---|---|
terraform fmt -check | any time, no init | formatting | free |
terraform validate | after init, no credentials | syntax, types, missing args, backend block (1.15+) | free |
variable validation | plan | bad inputs | free |
precondition / postcondition | plan and apply | broken assumptions | free |
check block assert | after apply | live health, as a warning | free |
terraform test | on demand | module behaviour | plan free, apply costs money |
Variable Validation
variable "environment" { type = string validation { condition = contains(["dev", "staging", "prod"], var.environment) error_message = "environment must be dev, staging, or prod." } } variable "cidr_block" { type = string validation { condition = can(cidrhost(var.cidr_block, 0)) error_message = "cidr_block must be valid CIDR notation." } validation { condition = tonumber(split("/", var.cidr_block)[1]) <= 20 error_message = "cidr_block must be /20 or larger." } }
Multiple validation blocks are allowed and all of them are evaluated, so you get every failure in one plan rather than one at a time.
Cross-variable validation (1.9+). Conditions used to see only their own variable. Since 1.9 they are ordinary expressions and may reference other variables, locals and data sources.
variable "min_size" { type = number } variable "max_size" { type = number validation { condition = var.max_size >= var.min_size error_message = "max_size must be greater than or equal to min_size." } }
error_message must be a single-sentence string. Terraform prints it verbatim, so write it for the person who set the variable wrong.
Preconditions, Postconditions and check Blocks
precondition and postcondition live inside lifecycle (or directly in an output) and fail the operation. check blocks (1.5+) are standalone and only warn.
resource "aws_instance" "web" { ami = var.ami_id lifecycle { precondition { condition = data.aws_ami.selected.architecture == "arm64" error_message = "This instance type requires an arm64 AMI." } postcondition { condition = self.private_ip != "" error_message = "Instance came up without a private IP." } } } check "api_reachable" { data "http" "healthz" { url = "https://${aws_lb.api.dns_name}/healthz" } assert { condition = data.http.healthz.status_code == 200 error_message = "API health check did not return 200." } }
precondition | postcondition | check | |
|---|---|---|---|
| Where | lifecycle or output | lifecycle or output | top level |
| When | before create or update | after the object exists | end of plan and apply |
| On failure | operation errors | operation errors | warning, exit code stays 0 |
self available | no | yes | no |
Own scoped data block | no | no | yes, exactly one |
Use check for continuous verification of things outside Terraform's control, such as an endpoint responding. Use conditions when a failure genuinely should stop the apply.
terraform test and .tftest.hcl (1.6+)
Test files end in .tftest.hcl (or .tftest.json) and live in tests/ by convention. Terraform runs them from the root configuration directory, not from tests/.
A file holds at most one test block, zero or more provider blocks, an optional file-level variables block, and one or more run blocks executed in order.
# tests/naming.tftest.hcl variables { bucket_prefix = "acme-test" environment = "dev" } provider "aws" { region = "us-east-1" } # Unit test: no infrastructure created run "name_is_prefixed" { command = plan assert { condition = aws_s3_bucket.app.bucket == "acme-test-app" error_message = "bucket name was ${aws_s3_bucket.app.bucket}" } } # Reject a bad input without creating anything run "rejects_bad_environment" { command = plan variables { environment = "banana" # overrides the file-level value } expect_failures = [ var.environment, ] } # Integration test: creates and then destroys real resources run "bucket_really_exists" { command = apply assert { condition = output.bucket_arn != "" error_message = "module produced no bucket ARN" } }
run block reference
| Field | Values | Default |
|---|---|---|
command | plan or apply | apply |
plan_options.mode | normal or refresh-only | normal |
plan_options.refresh | bool | true |
plan_options.replace / .target | list of addresses | — |
variables | block, overrides file-level values | — |
module | block, source and version only | config under test |
providers | map, remaps provider configs | inherited |
assert | zero or more blocks | — |
expect_failures | list of addresses expected to fail | — |
state_key | which internal state file to use | per module |
parallel | bool, run alongside other parallel runs | false |
Assertions can reference anything the configuration can, plus output.<name> from the current run and from earlier runs, which is how you chain a setup run into a verification run.
Setup and verification modules
The module block inside a run points that run at a different configuration. Only source and version are allowed, and only local and registry sources work.
run "setup" { module { source = "./testing/setup" # create prerequisites } } run "main" { # empty: exercises the configuration under test } run "verify" { module { source = "./testing/loader" # data sources that read it back } assert { condition = length(data.aws_s3_objects.objects.keys) == 2 error_message = "wrong number of objects created" } }
Provider mocking (1.7+) lets command = plan runs work against fake provider data, which is how you unit-test a module with no credentials at all.
Running tests
terraform test # every *.tftest.hcl terraform test -filter=tests/naming.tftest.hcl # one file terraform test -verbose # print each plan or state terraform test -json # machine-readable terraform test -junit-xml=report.xml # CI test report (GA in 1.11) terraform test -test-directory=integration # default is tests terraform test -parallelism=4 # default 10
command = applyruns create real, billable infrastructure. Terraform destroys it afterwards and reports anything it could not clean up. Point tests at a disposable account you can safely purge, and prefercommand = planfor logic.
plan -detailed-exitcode, the CI Primitive
Plain terraform plan exits 0 whether or not there are changes, which is useless for gating. -detailed-exitcode fixes that.
| Exit code | Meaning |
|---|---|
0 | succeeded, empty diff, nothing to do |
1 | error |
2 | succeeded, non-empty diff, changes are pending |
set +e terraform plan -input=false -detailed-exitcode -out=plan.tfplan code=$? set -e case $code in 0) echo "no changes, skipping apply" ;; 2) echo "changes pending, awaiting approval" terraform show -no-color plan.tfplan > plan.txt ;; *) echo "plan failed"; exit 1 ;; esac
Two habits that go with it: always -out the plan and apply that file so the applied change is the reviewed change, and read the plan with terraform show -json plan.tfplan when a policy tool needs to inspect it.
Linters, Policy and Scanners
| Tool | What it checks |
|---|---|
tflint | provider-aware lint: invalid instance types, deprecated syntax, naming rules |
checkov | security and compliance misconfiguration across many IaC formats |
trivy config | successor to tfsec, scans HCL for insecure settings |
terraform-compliance | BDD-style ("given/when/then") policy over a plan file |
OPA with conftest | Rego policy over terraform show -json output |
| Sentinel | HashiCorp's policy-as-code, enforced by HCP Terraform |
infracost | cost delta of a plan, catches an accidental expensive instance |
tflint --recursive checkov -d . --framework terraform trivy config . terraform show -json plan.tfplan > plan.json && conftest test plan.json
terratest, the Heavy Option
Go-based integration testing. Reach for it only when you need real HTTP calls, retries, SSH or Kubernetes clients that .tftest.hcl cannot express.
func TestS3Module(t *testing.T) { opts := &terraform.Options{ TerraformDir: "../examples/basic", Vars: map[string]interface{}{"bucket_prefix": "tt-acme"}, } defer terraform.Destroy(t, opts) terraform.InitAndApply(t, opts) arn := terraform.Output(t, opts, "bucket_arn") assert.Contains(t, arn, "arn:aws:s3:::") }
It is slower, needs a Go toolchain, and creates real resources. Start with terraform test.
A CI Pipeline That Holds
#!/usr/bin/env bash set -euo pipefail terraform fmt -check -recursive # 1. no credentials needed terraform init -input=false -backend=false terraform validate # 2. still no credentials tflint --recursive # 3. lint terraform test # 4. plan-only tests terraform init -input=false # 5. real backend set +e terraform plan -input=false -detailed-exitcode -out=plan.tfplan code=$? set -e [ "$code" -eq 1 ] && exit 1 terraform show -json plan.tfplan > plan.json conftest test plan.json # 6. policy gate if [ "$code" -eq 2 ] && [ "${APPROVED:-}" = "true" ]; then terraform apply -input=false plan.tfplan # 7. the reviewed plan only fi
Steps 1 to 4 need no cloud credentials, so they run on every pull request. Steps 5 and 6 need read access. Only step 7 needs write access, so scope the CI role accordingly.