Review catches a lot, but people get tired. Policy as code checks every plan against rules automatically, before apply.
flowchart LR PL["terraform plan -out=tfplan"] --> JS["terraform show -json > plan.json"] JS --> CF["conftest test plan.json"] RG["policy/*.rego"] --> CF CF -->|"pass"| AP["Apply allowed"] CF -->|"deny"| BL["Pipeline fails with messages"] %% step PL: The pipeline creates a saved plan as usual. %% step JS: The JSON plan lists every resource change with before and after values. %% step RG: Rules are written in Rego and kept in Git next to the code. %% step CF: conftest evaluates the rules against plan.json. %% step BL: Any deny message fails the job and tells the author exactly what to fix.
What the JSON plan looks like
{
"resource_changes": [
{
"address": "docker_container.debug",
"type": "docker_container",
"change": {
"actions": ["create"],
"after": { "name": "debug", "privileged": true, "labels": [] }
}
}
]
}
A Rego policy for conftest
# policy/containers.rego
package main
deny contains msg if {
rc := input.resource_changes[_]
rc.type == "docker_container"
rc.change.after.privileged == true
msg := sprintf("%s must not run privileged", [rc.address])
}
deny contains msg if {
rc := input.resource_changes[_]
rc.change.actions[_] == "delete"
startswith(rc.address, "module.db")
msg := sprintf("%s: deleting database resources needs a manual change request", [rc.address])
}
terraform show -json tfplan > plan.json
conftest test plan.json # reads ./policy by default
deny rules fail the run; warn rules only print. Test your policies too: opa test policy/ with example inputs.
Other options
| Tool | Where it runs | Language |
|---|---|---|
| OPA / conftest | Any CI | Rego |
| Sentinel | HCP Terraform / Terraform Enterprise | Sentinel |
| OPA in HCP Terraform | Policy sets on workspaces | Rego |
| Trivy / Checkov | Any CI, on code or plan JSON | Built-in checks plus custom ones |
| Cloud-native policies | AWS SCPs, Azure Policy, GCP org policies | Enforced by the cloud at API level |
Plan-time policies give fast feedback; cloud-native policies are the last line of defence because they also stop changes made outside Terraform.
Good rules to start with
- Required tags or labels (
owner,env,cost-center). - No public exposure: open
0.0.0.0/0ingress, public buckets, privileged containers. - Allowed regions, sizes or images.
- No deletion of stateful resources without an explicit override.
Traps: a policy that checks change.after misses values that are (known after apply), which appear under after_unknown; and a rule written for the old Rego syntax (deny[msg] { ... }) fails to parse in OPA 1.x, which requires contains and if.