Testing Terraform with Policy as Code

DevOpsInfrastructure as CodeTesting
Share on LinkedIn Share on X Share on Reddit Share on HN Share on Bluesky

One mistyped acl = "public-read" in a module default can expose every new S3 bucket across four environments before a human notices in review. Infrastructure as code magnifies mistakes at machine speed. Testing Terraform requires two layers most teams conflate: module correctness (does this code build what the author intended?) and organizational compliance (is what it builds allowed?). Native terraform test handles the first; policy as code (OPA/Conftest, Sentinel, Checkov) handles the second. Wire both into CI between plan and apply.

The validation pyramid for infrastructure

                    ┌─────────────────┐
                    │  Cloud SCPs     │  Last line — org guardrails
                    ├─────────────────┤
                    │  Policy on plan │  Conftest / Sentinel
                    ├─────────────────┤
                    │  Static analysis│  Checkov / tfsec on .tf
                    ├─────────────────┤
                    │  terraform test │  Module correctness
                    ├─────────────────┤
                    │  fmt / validate │  Syntax and provider schema
                    └─────────────────┘

Each layer catches different failure classes. Static analysis runs before plan — fast but misses computed values. Plan-level policy sees resolved attributes. SCPs catch bypass even if CI is compromised.

Layer 1: module correctness with terraform test

Since Terraform 1.6, .tftest.hcl files run modules with assertions:

run "blocks_public_access" {
  command = plan
  variables { name = "assets", environment = "prod" }
  assert {
    condition     = aws_s3_bucket.this.bucket == "assets-prod"
    error_message = "Bucket name must include environment suffix"
  }
  assert {
    condition     = aws_s3_bucket_public_access_block.this.block_public_acls == true
    error_message = "Public ACLs must be blocked"
  }
}

command = plan — fast, no cloud cost, catches logic and naming. Reserve command = apply for integration tests in ephemeral accounts verifying behavior plan cannot see (actual IAM propagation, connectivity checks).

Run terraform test in module repo CI before tagging releases. Module authors own these tests like unit tests in application code.

Mock providers for fast module CI

Terraform 1.7+ mocking enables plan tests without cloud credentials:

mock_provider "aws" {
  mock_data "aws_availability_zones" {
    defaults = { names = ["us-east-1a", "us-east-1b", "us-east-1c"] }
  }
}

Module CI completes in seconds on every push — reserve real cloud apply for weekly integration in ephemeral accounts.

Layer 2: policy as code on plan JSON

Module tests prove your module works. They do not stop a developer from writing correct HCL that provisions forbidden resources — public RDS, t2.micro in prod, missing cost_center tag.

Export plan to JSON:

terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json

Evaluate with Conftest (OPA/Rego):

package terraform.s3
import rego.v1

deny contains msg if {
    some resource in input.resource_changes
    resource.type == "aws_s3_bucket_public_access_block"
    resource.change.after.block_public_acls == false
    msg := sprintf("S3 public ACLs must be blocked: %s", [resource.address])
}

deny contains msg if {
    some resource in input.resource_changes
    resource.type == "aws_instance"
    resource.change.after.instance_type == "t2.micro"
    input.variables.environment.value == "prod"
    msg := sprintf("t2.micro not allowed in prod: %s", [resource.address])
}
conftest test tfplan.json --policy policies/ --all-namespaces

Fail CI on non-zero exit. Policies live in central repo — security team owns Rego, app teams consume gate.

Policy engine comparison

Tool Input Strength Integration
Conftest/OPA Plan JSON, K8s YAML Open source, multi-cloud CLI in any CI
HashiCorp Sentinel Plan, runtime values Native HCP Terraform Policy sets in TFC
Checkov / tfsec .tf files Pre-plan, fast feedback GitHub Action
Infracost Plan JSON Cost delta policies FinOps gate
AWS SCPs API calls Org-wide enforcement Cannot bypass in account

Defense in depth: Checkov on PR → plan → Conftest on plan JSON → SCP prevents apply even if CI bypassed.

Pipeline placement and timing

PR opened
  → terraform fmt -check
  → terraform validate
  → checkov -d .
  → terraform plan -out=plan.bin
  → terraform show -json > plan.json
  → conftest test plan.json
  → terraform test
Merge → apply with approval gate

Policy on plan output catches resources about to change, including destroys and replacements. Target under two minutes for static + plan + policy on typical root module.

Testing destructive changes

Policies can require explicit approval for delete actions:

deny contains msg if {
    some resource in input.resource_changes
    resource.change.actions[_] == "delete"
    resource.type == "aws_rds_cluster"
    not input.metadata.labels.approved_destroy
    msg := sprintf("RDS destroy requires data platform approval: %s", [resource.address])
}

Load destroy policies only for prod plan artifacts — dev sandboxes remain fast.

Negative and positive test fixtures

Maintain regression fixtures for policy rules:

testdata/bad_plan_public_s3.json    # must fail s3.rego
testdata/good_plan_compliant.json   # must pass all deny rules

CI runs Conftest against fixtures on every policy repo PR — refactors cannot silently weaken rules.

Exception and waiver workflow

Break-glass policy_waiver label on PR requires two security approvers; Conftest skips deny rules only for labeled plans; waivers expire in 7 days if not applied. Log waivers to SIEM with approver identity — compliance reviews quarterly.

Pin Conftest policy bundle git SHA in CI workflow, not floating main — deploy pipeline must not change compliance rules mid-apply without explicit policy repo release.

Cost estimation as policy

Integrate Infracost on plan JSON — policy denies PR if monthly delta exceeds $500 without cost-approved label. FinOps and security evaluate same plan artifact.

Consumer repo testing

Root modules run terraform test against pinned child module versions in CI — catches module upgrade breaking consumer assumptions before merge even when module's own tests passed.

Measuring policy effectiveness

Track policy violations caught pre-apply, mean time to add policy after incident, and waiver count. Goal: incidents from misconfig trend down while PR velocity stays flat.

Synthesis

terraform test for module contracts; Conftest/Sentinel on plan JSON for org rules; static analysis upstream; SCPs downstream. The pipeline between plan and apply is where infrastructure governance becomes enforceable — not wiki guidance, but code that blocks the merge.

Static analysis upstream of plan

Checkov and tfsec scan .tf files before plan — catches hardcoded secrets in HCL:

checkov -d . --framework terraform
terraform plan -out=plan.bin
terraform show -json plan.bin > plan.json
conftest test plan.json --policy policies/

Static analysis catches issues earlier; plan policy catches computed values resolved only at plan time.

Ephemeral environments for apply tests

Spin ephemeral env per PR in sandbox account for integration confidence beyond plan-level tests. Cost-controlled with TTL and small instance sizes.

Policy version pinning in CI

- uses: actions/checkout@v4
  with:
    repository: org/terraform-policies
    ref: v2.4.1
    path: policies

Infrastructure deploy pipeline must not change compliance rules mid-apply without explicit policy repo release.

Developer experience metrics

Track policy violation rate per team over time. Rising violations after new rule indicate unclear error messages — iterate policy messages until developers self-serve fixes without platform team Slack pings.

Signed OPA policy bundles

Cosign-sign policy bundles; CI verifies signature before conftest test. Tampered Rego cannot weaken public S3 deny rule without detection.

Plan JSON schema validation

Validate tfplan.json against Terraform plan schema before Conftest — malformed plan from provider crash should fail CI loudly, not produce empty policy pass.

Checkov skip comment audit

checkov:skip comments require ticket ID in same line — monthly grep finds skips without tickets.

Sentinel vs OPA trade-offs

Sentinel integrates natively with HCP Terraform policy sets — evaluate at plan and apply in managed runs. OPA/Conftest runs anywhere — preferred for multi-cloud and polyglot pipelines. Many orgs run Conftest in CI and Sentinel in TFC for defense in depth.

Policy rollout strategy

Start new policies in advisory mode for two weeks — log violations without blocking. Measure false positive rate before switching to enforce mode. Grandfather existing violations with ticket-linked exception tags expiring quarterly.

Integration with HCP Terraform run tasks

Run tasks execute Conftest against plan JSON in Terraform Cloud before apply gate — same policies as CI, enforced at apply time for teams using VCS-driven workflows.

Resources

Frequently asked questions

What is policy as code for Terraform?

Policy as code means expressing your infrastructure rules — like 'no public S3 buckets' or 'every resource must be tagged with a cost center' — as machine-evaluable code rather than wiki guidelines. Tools like Open Policy Agent (Conftest) or HashiCorp Sentinel evaluate a Terraform plan against these policies and fail the pipeline if any rule is violated, enforcing standards automatically.

How is terraform test different from policy as code?

They test different things. The native 'terraform test' framework verifies your module behaves correctly — given these inputs, does it produce the expected resources and outputs. Policy as code verifies the resulting infrastructure meets organizational rules like security and tagging. You want both: correctness tests for the module author, policy checks for the whole org.

Where in the pipeline should policy checks run?

Run them against the plan output, after 'terraform plan' and before 'terraform apply', in CI on every pull request. Evaluating the plan JSON lets you catch violations before anything is provisioned. For defense in depth, many teams also enforce policies at the state or cloud level, but the PR gate is the fastest, cheapest feedback.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →