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:

Bash
# 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:

YAML
# .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:

Bash
tflint --init          # downloads the AWS (or other provider) rule plugin
tflint
Plaintext
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:

Bash
checkov -d infra/
Plaintext
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:

Hcl
# 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"
  }
}
Bash
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 apply directly from a pull request branch instead of gating it behind a merge to main — this defeats the entire purpose of code review for infrastructure changes.
  • Treating terraform validate as sufficient on its own — it catches syntax and type errors but has no idea whether t3.mega is 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 plan only 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 plan output 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.