A ladder of checks

Infrastructure code fails in expensive ways: a wrong plan can delete a database, and a playbook with a typo can stop nginx on every web server at once. Testing infrastructure therefore follows the same idea as the test pyramid for applications: many fast, cheap checks that run on every change, and fewer slow, realistic ones. Each rung catches a different kind of mistake.

The ladder of checks for infrastructure code, from left to right: validate with tofu fmt and tofu validate; lint with TFLint and ansible-lint; scan with Checkov and Trivy; policy with OPA and Conftest on the plan JSON; test with tofu test and Molecule. Checks on the left take seconds and run on every save; checks on the right take minutes and run on every pull request. Validate tofu fmt tofu validate Lint TFLint ansible-lint Scan Checkov Trivy Policy OPA, Conftest on plan JSON Test tofu test Molecule seconds, every save minutes, every pull request
Cheap checks first. A failure on the left stops the run before the slower checks start.
Rung Catches Needs
Format and validate Untidy code, syntax errors, typos in argument names, wrong types, undeclared variables Only the tool (and providers, for validate)
Lint Valid code that is still wrong or risky: unused variables, short module names, file permissions not set The linter and its rule plugins
Scan Insecure settings: public buckets, unencrypted disks, a firewall rule open to 0.0.0.0/0 The scanner; works on files or plan JSON
Policy Breaches of your own rules: missing owner tags, wrong regions, destroying protected resources A plan (so state and credentials) plus the policies
Test Logic errors: a template that renders the wrong file, a role that is not idempotent Time; sometimes real resources or a test machine

None of this replaces reading the plan. The checks make sure the plan a reviewer reads is worth reading; the reviewer still decides whether "1 to destroy" is intended. In a pipeline the whole ladder becomes a quality gate (see Infrastructure as Code in Pipelines).

Formatting and validation

tofu fmt rewrites files into the canonical style: two spaces of indentation and aligned = signs. With -check it changes nothing, prints the names of files that are not formatted, and exits with a non-zero code — exactly what a pipeline needs. Joost Bakker's new file for the nightly billing export was typed in a hurry:

cat billing.tf
tofu fmt -check
tofu fmt -diff
tofu fmt -check
resource "local_file" "billing_cron" {
filename = "${path.module}/out/billing-export.cron"
    content = "30 2 * * * billing /opt/kestrel/bin/export-billing --format csv\n"
  file_permission="0640"
}

billing.tf

billing.tf
--- old/billing.tf
+++ new/billing.tf
@@ -1,5 +1,5 @@
 resource "local_file" "billing_cron" {
-filename = "${path.module}/out/billing-export.cron"
-    content = "30 2 * * * billing /opt/kestrel/bin/export-billing --format csv\n"
-  file_permission="0640"
+  filename        = "${path.module}/out/billing-export.cron"
+  content         = "30 2 * * * billing /opt/kestrel/bin/export-billing --format csv\n"
+  file_permission = "0640"
 }

The first -check printed billing.tf and exited with code 3; -diff showed the change and rewrote the file; the second -check printed nothing and exited with 0. Add -recursive to include subdirectories such as modules/. With Terraform the commands are the same: terraform fmt -check.

tofu validate goes one step further: it loads the configuration with the provider schemas (so it needs tofu init first) and reports unknown arguments, wrong types and references to things that do not exist. It does not contact any cloud API and does not read the state, so it is safe to run anywhere. The typical errors and their fixes are on Troubleshooting Infrastructure as Code; a clean run prints one line:

Success! The configuration is valid.

Built-in linting in OpenTofu 1.13

As of October 2026, OpenTofu 1.13 has experimental built-in lint rules, enabled with a -lint flag on validate, plan, apply and refresh. The rules include core:no-type-variable (a variable without a type), core:unused-variable, core:unused-local and core:count-instead-enabled (a count = var.x ? 1 : 0 that could use enabled in OpenTofu's lifecycle block). Findings are warnings, and OpenTofu also warns that the feature is experimental and may change in minor releases. Terraform has no equivalent flag.

tofu validate -lint='all,!core:unused-local'
╷
│ Warning: Variable with no type (core:no-type-variable)
│
│   on variables.tf line 12:
│   12: variable "legacy_port" {
│
│ Variable "legacy_port" has no type specified.
╵
…

Linters

A linter looks for code that works but is likely to cause trouble later. Infrastructure code has two well-known ones.

TFLint

TFLint is an open-source linter for Terraform and OpenTofu configuration (MPL 2.0; v0.64.0 as of October 2026). Its rules come in plugins: a bundled terraform ruleset for language-level problems, and separate rulesets for AWS, Azure and Google Cloud that know, for example, which virtual machine sizes exist — something validate cannot check, because to the provider schema an instance type is just a string. The configuration lives in .tflint.hcl:

plugin "terraform" {
  enabled = true
  preset  = "recommended"
}

Cloud rulesets are added as further plugin blocks with a source and a pinned version; tflint --init downloads them. Then tflint (or tflint --recursive for a repository with modules) runs the rules:

tflint --init
tflint
2 issue(s) found:

Warning: `legacy_port` variable has no type (terraform_typed_variables)

  on variables.tf line 12:
  12: variable "legacy_port" {

Warning: variable "legacy_port" is declared but not used (terraform_unused_declarations)

  on variables.tf line 12:
  12: variable "legacy_port" {

…

ansible-lint

ansible-lint checks playbooks, roles and collections against the Ansible community's best practices. It is a separate Python package (26.9.0 as of October 2026, calendar versioning) and supports only the last two major versions of Ansible. Rules have names such as fqcn[action-core] (use ansible.builtin.copy, not copy), no-changed-when (a command task that always reports "changed"), risky-file-permissions, yaml[truthy] and yaml[octal-values] (the YAML traps from Configuration Languages). Profiles group the rules from lenient to strict: min, basic, moderate, safety, shared and production.

Hamid Rahimi's quick playbook for the billing export settings:

- name: Billing export settings
  hosts: db
  gather_facts: false
  tasks:
    - name: Write the export settings
      copy:
        content: "format=csv\n"
        dest: /etc/kestrel/billing-export.ini
ansible-lint billing.yml
fqcn[action-core]: Use FQCN for builtin module actions (copy).
billing.yml:5 Use `ansible.builtin.copy` or `ansible.legacy.copy` instead.

risky-file-permissions: File permissions unset or incorrect.
billing.yml:5 Task/Handler: Write the export settings

Read documentation for instructions on how to ignore specific rule violations.

# Rule Violation Summary

…

Failed: 2 failure(s), 0 warning(s) in 1 files processed of 1 encountered. Last profile that met the validation criteria was 'moderate'. Rating: 2/5 star

The fix is ansible.builtin.copy with mode: "0640" (quoted). Without a mode, the file gets whatever the remote user's umask gives, which may be readable by everyone on db01.

Before any linter, Ansible itself can check that a playbook parses. --syntax-check loads the playbook, its roles and its includes without connecting to a host:

ansible-playbook site.yml --syntax-check

playbook: site.yml

The test command

Linters read code; tests run it. Since version 1.6, both OpenTofu (tofu test) and Terraform (terraform test) have a built-in test command. A test file ends in .tftest.hcl, usually sits in a tests/ folder next to the module, and contains run blocks. Each run block plans or applies the module and checks assert conditions against the result. Ravi Kumar wrote this test for the nginx-upstream module that renders the portal's load-balancer configuration (the module itself is on Modules and Reuse):

tests/upstream.tftest.hcl

variables {
  name       = "kestrel_portal"
  servers    = ["192.0.2.11:8080", "192.0.2.12:8080"]
  output_dir = "./test-out"
}

run "renders_one_line_per_server" {
  command = plan

  assert {
    condition     = length(regexall("server ", local_file.this.content)) == 2
    error_message = "Expected one server line per backend."
  }

  assert {
    condition     = local_file.this.filename == "./test-out/kestrel_portal.conf"
    error_message = "The file name must follow <output_dir>/<name>.conf."
  }
}

run "rejects_empty_server_list" {
  command = plan

  variables {
    servers = []
  }

  expect_failures = [var.servers]
}

run "writes_the_file" {
  command = apply

  assert {
    condition     = fileexists(output.path)
    error_message = "The upstream file was not written."
  }
}
  • The top-level variables block gives every run the same inputs; a run can override them, as the second one does.
  • command = plan creates nothing and is fast; it can check every value that is known at plan time.
  • expect_failures = [var.servers] turns an expected error into a pass: the module's validation rule must reject an empty list. Testing that bad input is refused is as important as testing that good input works.
  • command = apply really creates the resources, and tofu test destroys them again at the end. With a cloud provider that means real (billed) resources in a test account — and a cleanup to check if a test run is killed.
tofu test
tests/upstream.tftest.hcl... pass
  run "renders_one_line_per_server"... pass
  run "rejects_empty_server_list"... pass
  run "writes_the_file"... pass

Success! 3 passed, 0 failed.

To see a failure, the first assertion was changed to expect three server lines. The failing run shows the values involved and a diff, and the command exits with code 1, which stops a pipeline:

tests/upstream.tftest.hcl... fail
  run "renders_one_line_per_server"... fail
╷
│ Error: Test assertion failed
│
│   on tests/upstream.tftest.hcl line 11, in run "renders_one_line_per_server":
│   11:     condition     = length(regexall("server ", local_file.this.content)) == 3
│     ├────────────────
│     │ local_file.this.content is "upstream kestrel_portal {\n    server 192.0.2.11:8080;\n    server 192.0.2.12:8080;\n}\n"
│     ├────────────────
│     │ Diff:
│     │     2 -> 3
│
│ Expected one server line per backend.
╵
  run "rejects_empty_server_list"... pass
  run "writes_the_file"... pass

Failure! 2 passed, 1 failed.

Tests against a cloud do not always need the cloud. A mock_provider "aws" {} block in a test file replaces the real provider with one that invents values for computed attributes, so a plan-and-assert test runs without credentials (OpenTofu 1.8 and later, Terraform 1.7 and later). Mocks prove the logic of the module, not that AWS accepts the result; keep a few real apply tests in a sandbox account for that.

Testing Ansible roles with Molecule

Molecule is the testing framework for Ansible roles, playbooks and collections (MIT license; 26.9.0 as of October 2026, released together with ansible-lint). A scenario describes a disposable test machine, usually a container or a small virtual machine. The default molecule test sequence is:

  1. dependency, cleanup, destroy: install required collections, remove leftovers of an earlier run;
  2. syntax, create, prepare: check the syntax, create the test machine, put prerequisites on it;
  3. converge: apply the role;
  4. idempotence: apply it again and fail if any task reports changed;
  5. side_effect, verify: optionally disturb the machine, then run checks (by default an Ansible playbook with assert tasks);
  6. cleanup, destroy.

The idempotence step is the one that pays off most often. A role that reports "changed" on every run hides real changes in noise and restarts services for nothing. The check is easy to do by hand on a test inventory: run the playbook twice and read the second PLAY RECAP. For the Kestrel web servers the second run looked like this:

PLAY RECAP *********************************************************************
db01                       : ok=2    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0
web01                      : ok=4    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
web02                      : ok=4    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

changed=0 on every host is what Molecule's idempotence step checks for you, on a clean machine, for every pull request. The playbook itself and the first run are on Playbooks and Idempotency.

Security scanners

A security scanner is static analysis with hundreds of built-in rules about insecure settings, the infrastructure counterpart of static application security testing. It complements the cloud's own misconfiguration scanning: the scanner finds the problem in the pull request, the cloud finds it after it exists.

Checkov (Apache 2.0, maintained by Prisma Cloud at Palo Alto Networks; 3.3.25 as of October 2026) scans Terraform and OpenTofu files, plan JSON, CloudFormation, Kubernetes manifests, Dockerfiles and more. Each rule has an ID such as CKV_AWS_21. A new bucket for the billing exports, with nothing but a tag, fails several checks at once (versioning, access logging, cross-region replication, KMS encryption, public access block, …); the first finding:

resource "aws_s3_bucket" "billing_exports" {
  bucket = "kestrel-billing-exports-test"

  tags = {
    owner = "joost.bakker"
  }
}
checkov -d . --compact
terraform scan results:

Check: CKV_AWS_21: "Ensure all data stored in the S3 bucket have versioning enabled"
	FAILED for resource: aws_s3_bucket.billing_exports
	File: /storage.tf:1-7
…

Some findings do not apply. Checkov accepts a skip comment inside the resource, and the comment must say why, so the next reviewer can judge whether the reason still holds:

resource "aws_s3_bucket" "billing_exports" {
  #checkov:skip=CKV_AWS_144:Test exports are recreated nightly; no cross-region copy needed
  bucket = "kestrel-billing-exports-test"

  tags = {
    owner = "joost.bakker"
  }
}

Trivy, best known as a container image scanner, also scans infrastructure code; tfsec, once the popular Terraform scanner, has been folded into Trivy and its README asks users to move. If you run a scanner as a GitHub Action, pin it to a commit SHA: in March 2026 the version tags of the Trivy action were moved to malicious code (see container security and pinning actions). A scanner runs with the pipeline's permissions, which makes it an attractive supply chain attack target.

Policy as code

Scanners know general best practice. Every organization also has its own rules: Kestrel Energy requires an owner tag on every bucket and server, allows resources only in EU regions, and never wants PostgreSQL on db01 reachable from the internet. Policy as code means writing such rules in a machine-readable language, keeping them in Git with reviews and history, and evaluating them automatically on every change — instead of in a wiki page that reviewers are supposed to remember.

Open Policy Agent and Rego

Open Policy Agent (OPA) is a general-purpose policy engine, a graduated project of the Cloud Native Computing Foundation (CNCF); v1.21.1 as of October 2026. It takes a JSON document as input, evaluates policies against it, and returns a decision, so it acts as a policy decision point. Policies are written in Rego, OPA's declarative query language: a rule describes the conditions under which something is true, and every condition in the rule body must hold.

Policies should check the plan, not the HCL. In the source files a value may be a variable, a module output or a function call; in the plan JSON (tofu show -json tfplan) every value that is known has been worked out. The Kestrel policy file:

policy/kestrel.rego

package kestrel.plan

tagged_types := {"aws_s3_bucket", "aws_instance", "aws_db_instance"}

deny contains msg if {
	some rc in input.resource_changes
	rc.type == "aws_security_group_rule"
	after := rc.change.after
	after.from_port <= 5432
	after.to_port >= 5432
	"0.0.0.0/0" in after.cidr_blocks
	msg := sprintf("%s opens PostgreSQL (5432) to the whole internet", [rc.address])
}

deny contains msg if {
	some rc in input.resource_changes
	rc.type in tagged_types
	rc.change.after != null
	not rc.change.after.tags.owner
	msg := sprintf("%s has no owner tag", [rc.address])
}
  • deny contains msg if { … } adds a message to the set deny for every combination of values that makes all lines in the body true. Two rules with the same name add to the same set.
  • some rc in input.resource_changes loops over every planned change; rc.change.after is the resource as it will be after the apply (null for a deletion).
  • not rc.change.after.tags.owner is true when the tag is missing, or when there are no tags at all.

Conftest (v0.71.1 as of October 2026) is a small command-line tool that runs Rego policies against configuration files, including plan JSON. In the pipeline, after the plan:

tofu show -json tfplan > tfplan.json
conftest test --policy policy/ --namespace kestrel.plan tfplan.json
FAIL - tfplan.json - kestrel.plan - aws_instance.web03 has no owner tag
FAIL - tfplan.json - kestrel.plan - aws_security_group_rule.db01_postgres opens PostgreSQL (5432) to the whole internet

2 tests, 0 passed, 0 warnings, 2 failures, 0 exceptions

The same policy, step by step

The logic of a policy is easier to see without Rego's syntax. This program applies the same two rules to a shortened plan JSON with four changes: a tagged bucket, an open firewall rule, an untagged server and a server being deleted.

import json

# A shortened excerpt of `tofu show -json tfplan` for a planned change
plan = json.loads("""
{
  "resource_changes": [
    {"address": "aws_s3_bucket.billing_exports",
     "type": "aws_s3_bucket",
     "change": {"actions": ["create"],
                "after": {"bucket": "kestrel-billing-exports",
                          "tags": {"owner": "joost.bakker"}}}},
    {"address": "aws_security_group_rule.db01_postgres",
     "type": "aws_security_group_rule",
     "change": {"actions": ["create"],
                "after": {"from_port": 5432, "to_port": 5432,
                          "cidr_blocks": ["0.0.0.0/0"]}}},
    {"address": "aws_instance.web03",
     "type": "aws_instance",
     "change": {"actions": ["create"],
                "after": {"instance_type": "t4g.small", "tags": null}}},
    {"address": "aws_instance.web_old",
     "type": "aws_instance",
     "change": {"actions": ["delete"], "after": null}}
  ]
}
""")

TAGGED_TYPES = {"aws_s3_bucket", "aws_instance", "aws_db_instance"}


def deny(change):
    """Return the policy violations for one resource change."""
    after = change["change"]["after"]
    if after is None:  # being deleted: nothing to check
        return []
    problems = []
    if change["type"] == "aws_security_group_rule":
        opens_5432 = after["from_port"] <= 5432 <= after["to_port"]
        if opens_5432 and "0.0.0.0/0" in after["cidr_blocks"]:
            problems.append("opens PostgreSQL (5432) to the whole internet")
    if change["type"] in TAGGED_TYPES and not (after.get("tags") or {}).get("owner"):
        problems.append("has no owner tag")
    return problems


violations = []
for change in plan["resource_changes"]:
    for problem in deny(change):
        violations.append(f"{change['address']} {problem}")

for line in sorted(violations):
    print("DENY", line)
print(f"{len(violations)} violation(s): {'blocked' if violations else 'allowed'}")
DENY aws_instance.web03 has no owner tag
DENY aws_security_group_rule.db01_postgres opens PostgreSQL (5432) to the whole internet
2 violation(s): blocked

The deleted server is not checked: there is nothing left to tag. Whether deleting it is allowed is a different policy, and a good one to have for databases.

Other policy engines

HCP Terraform, HashiCorp's hosted service, runs policies in its own language Sentinel or in OPA as a step between plan and apply; as of October 2026 both are included in its free tier. Cloud platforms have their own policy services that act on the account itself (AWS Service Control Policies, Azure Policy, Google Cloud Organization Policy — see guardrails). These are complementary: the pipeline policy gives fast feedback in the pull request, the cloud policy also stops someone who bypasses the pipeline.

Where each check runs

Where What to run Why there
On the laptop, before a commit tofu fmt, tofu validate, ansible-playbook --syntax-check, linters Seconds; fixes before anyone else sees the change
Pipeline, every pull request The same again (laptops differ), scanners, plan, policy on the plan JSON, tofu test, Molecule Nobody can skip it; results sit next to the review
Pipeline, on a schedule Plan against production for drift; slow apply tests in a sandbox account Finds what changed outside the code

The same checks also apply to network configuration; the network team's version of this ladder, with Batfish, is on Network Automation with Git.