Policy as Code for Terraform With OPA and Sentinel
Stop relying on PR reviewers to catch the public S3 bucket. Here's how to enforce Terraform guardrails automatically with OPA/Conftest and Sentinel — and which checks are worth writing.
- #terraform
- #policy-as-code
- #opa
- #sentinel
- #security
- #ci
Every team has a list of rules that live in people’s heads: no public S3 buckets, all resources must be tagged, no 0.0.0.0/0 on port 22, instances stay under a certain size unless someone signs off. And every team eventually ships a violation of one of those rules, because the only thing enforcing them was a tired reviewer at 5pm on a Friday.
Policy as code moves those rules out of human memory and into your pipeline, where they fail the build instead of failing in production. Here’s how I set it up with the two tools you’ll actually encounter: Open Policy Agent (via Conftest) and HashiCorp Sentinel.
Policy runs against the plan, not the code
The key insight that makes policy-as-code work: you don’t write policies against your HCL. You write them against the plan output, converted to JSON. The plan is the resolved truth — after variables, locals, and modules have all been evaluated — so a policy sees exactly what Terraform is about to create.
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
That tfplan.json is what your policy engine inspects. The resource_changes array lists every resource and its post-apply attributes. Your policies assert facts about that array.
OPA and Conftest: the open path
Open Policy Agent uses a language called Rego, and Conftest is the wrapper that makes it pleasant to run against config files. A policy is a deny rule that produces a message when something is wrong.
Here’s a policy that blocks public S3 buckets:
package main
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket_acl"
resource.change.after.acl == "public-read"
msg := sprintf("S3 bucket ACL '%s' is public-read", [resource.address])
}
And one that enforces required tags on every taggable resource:
package main
required_tags := {"environment", "owner", "cost_center"}
deny[msg] {
resource := input.resource_changes[_]
tags := object.get(resource.change.after, "tags", {})
missing := required_tags - {tag | tags[tag]}
count(missing) > 0
msg := sprintf("%s is missing tags: %v", [resource.address, missing])
}
Run it in CI:
conftest test tfplan.json --policy policy/
Any deny that fires exits non-zero and breaks the build. Conftest is free, runs anywhere, and works with both Terraform and OpenTofu — it’s my default recommendation for teams not already in Terraform Cloud.
Sentinel: the Terraform Cloud/Enterprise path
If you’re on Terraform Cloud or Enterprise, Sentinel is the built-in option. It runs as a gate in the run pipeline and has first-class imports for the plan, config, and state. The same public-bucket idea in Sentinel:
import "tfplan/v2" as tfplan
s3_acls = filter tfplan.resource_changes as _, rc {
rc.type is "aws_s3_bucket_acl" and
rc.mode is "managed"
}
main = rule {
all s3_acls as _, acl {
acl.change.after.acl is not "public-read"
}
}
Sentinel’s killer feature is enforcement levels: a policy can be advisory (warn, don’t block), soft-mandatory (block, but an admin can override with a documented reason), or hard-mandatory (block, no override). That graduated enforcement is exactly what you want when rolling policy out to a team that didn’t have any — start advisory, watch what fires, then tighten.
Which policies are actually worth writing
Don’t try to encode every rule on day one. The policies that earn their place share a trait: a violation is expensive and a human reviewer reliably misses it. My starter set:
- No public storage. S3 ACLs, public RDS, public-facing databases. The classic breach vector.
- Required tags. Untagged resources wreck cost allocation and ownership. Cheap to enforce, constantly violated.
- No wide-open ingress.
0.0.0.0/0on SSH/RDP/database ports. Catches the copy-pasted security group. - Encryption on. EBS volumes, S3, RDS storage encrypted at rest.
- Size/cost ceilings. Block instance types above a threshold unless explicitly allowed — pairs naturally with cost estimation in the same pipeline.
Resist the urge to write policies that merely encode taste. Style belongs in a linter; policy is for rules that have real consequences when broken.
Make failures teach, not just block
A policy that fails with “denied” trains people to hate the pipeline. A policy that fails with why and how to fix it trains people to write compliant config. Always put the resource address and the remediation in the message:
msg := sprintf(
"%s allows ingress from 0.0.0.0/0 on port 22. Restrict to your VPN CIDR or a bastion SG.",
[resource.address],
)
The marginal effort of a good message is tiny and the payoff in team goodwill is large.
Wire it into the pipeline correctly
Policy runs after plan and before apply, on the JSON plan. The order in CI is: validate → plan → convert to JSON → policy check → (manual approval) → apply. Fail closed: if the policy step errors out, the build fails. A policy engine that silently passes on its own crash is worse than no policy at all.
Keep policies in their own directory in the repo, version them, and test them with sample plans so a broken policy gets caught before it gates everyone.
The payoff
Policy as code turns your tribal knowledge into an automated gate that never gets tired, never rubber-stamps a Friday PR, and never forgets the rule. Start with the handful of checks where a violation is genuinely expensive, write clear remediation messages, and roll out advisory-first so the team trusts the gate before it blocks them.
Policy catches the rules you’ve already codified; an extra reviewer catches the ones you haven’t thought to write yet. We built our AI code review tooling to flag risky infra changes that slip past static policies. For more on hardening your Terraform pipeline, see the full Terraform guides.
Examples are illustrative. Test policies against representative plans before enforcing them in a pipeline that gates production.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.