Stop terraform apply before it breaks production! Learn the Policy as Code concept with Open Policy Agent (Rego) and HashiCorp Sentinel to enforce security, tags, and cost rules automatically.

After discussing secret management and state file security in episode 15 — how to ensure secrets don't leak through state, and how to pull secrets from Vault, AWS Secrets Manager, and SOPS — in this episode we'll discuss the next line of defense: Policy as Code (PaC) using Open Policy Agent (OPA) and HashiCorp Sentinel.
This is a story that often happens in the real world: a new engineer is asked to add a security group rule to open the SSH port from home. Unconsciously, they type 0.0.0.0/0 as the CIDR — meaning port 22 is open to the entire internet. The code review is missed because the change looks "small and innocent". A few hours later, the production instance shows up on a bitcoin miner list. The opposite scenario is also common: an engineer casually creates a public S3 bucket just for a "quick test", or bootstraps EC2 without tags so the cost team can't make sense of the budget.
Humans inevitably make mistakes, and manual code review will never catch everything — especially when a pull request contains hundreds of lines of infrastructure changes. The solution is automating the rules themselves, making them part of the pipeline, and making them reviewable like code. That's the essence of Policy as Code.
The larger the infrastructure, the more impossible it becomes to review every line of HCL manually. The problem isn't just human fatigue, but also:
terraform apply runs — for example, a combination of two resources that creates public access.Note
PaC's position in the pipeline: it's not a replacement for linters (tflint) or static security scanners (Checkov/Trivy). Static scanners read the HCL code looking for misconfiguration patterns. PaC reads the terraform plan result — the actual condition that will be applied — so it can catch violations that only appear at runtime.
| Aspect | Static Scanning (Checkov/Trivy) | Policy as Code (OPA/Sentinel) |
|---|---|---|
| Input | HCL code / IaC files | terraform plan output (JSON) |
| When it runs | Early in the pipeline (lint/gate) | After plan, before apply |
| Capability | Detects static misconfig patterns | Evaluates the actual condition to be applied |
| Example | "There's acl = public-read in the code" | "This plan will open port 22 to 0.0.0.0/0" |
| Result nature | Pass/Fail per file | Pass/Fail per execution plan |
Think of PaC like an airport security checkpoint placed right before the departure gate. Passengers (plans) can queue up, but before boarding the plane (apply), they must pass the inspection (policy engine). If there's contraband (a violation), that passenger is stopped.
The PaC workflow with Terraform:
terraform plan -out=tfplan — compute the change plan.terraform show -json tfplan > tfplan.json — export the plan to machine-readable JSON.tfplan.json against the rule set.The key to this flow is terraform show -json: the entire change plan — which resources will be created/modified/deleted, which attributes will be applied — is turned into structured data that a policy engine can inspect.
terraform plan -out=tfplan # 1. Plan
terraform show -json tfplan > tfplan.json # 2. Export to JSON
opa eval ... # 3. Evaluate policy
terraform apply tfplan # 4. Only allowed hereThe two most popular tools for PaC in the Terraform ecosystem are:
Open Policy Agent (OPA) — an open-source policy engine backed by the CNCF, using the Rego language. OPA is generic: it can evaluate policies for Kubernetes, Envoy, HTTP APIs, and Terraform alike. Because it's open-source, it's easy to integrate into your own CI/CD pipeline.
HashiCorp Sentinel — a proprietary policy engine owned by HashiCorp that integrates natively with Terraform Cloud / HCP Terraform and Vault Enterprise. The Sentinel language is deliberately easy to read (similar to HCL) and uses the rule concept with enforcement levels (hard-mandatory vs soft-mandatory).
| Aspect | OPA / Rego | HashiCorp Sentinel |
|---|---|---|
| License | Open source (CNCF) | Proprietary (HashiCorp) |
| Policy language | Rego (declarative, set-based) | Sentinel (HCL-like) |
| Scope | Multi-platform: K8s, Terraform, HTTP, Envoy | HashiCorp ecosystem (Terraform, Vault, Consul) |
| Terraform integration | External — evaluate plan JSON via CLI / CI | Native in Terraform Cloud / HCP |
| Enforcement method | Gate scripts / supporting platforms (Spacelift, etc.) | Policy sets + enforcement levels |
| Cost | Free | Enterprise |
OPA is distributed as a single static binary. Let's install it:
# macOS (Homebrew)
brew install opa
# Linux (static binary)
curl -L -o opa https://openpolicyagent.org/downloads/latest/opa_linux_amd64_static
chmod +x opa
sudo mv opa /usr/local/bin/
# Verify
opa versionThe output will show the version and build commit:
Version: 1.x.x
Build Commit: 3f4f1b2
Build Timestamp: 2026-07-15T00:00:00Z
Go Version: go1.23.4
Platform: linux/amd64Let's start with the most common rule: no public S3 buckets. Policies are written in the Rego language. Each policy file declares a package (namespace) and a deny rule — if there's even one message inside deny, the plan violates the policy.
package terraform
import rego.v1
PUBLIC_ACLS := {"public-read", "public-read-write", "authenticated-read"}
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_s3_bucket"
rc.change.actions[_] != "delete"
rc.change.after.acl in PUBLIC_ACLS
msg := sprintf("%s: public S3 bucket is forbidden by organizational policy.", [rc.address])
}Let's break it down one by one:
package terraform — the policy namespace, so rules can be called via data.terraform.deny.import rego.v1 — modern Rego style (if, contains, in keywords).PUBLIC_ACLS — the set of ACL values considered dangerous.some rc in input.resource_changes — iterate over every resource in the plan.rc.change.actions[_] != "delete" — ignore resources being deleted (only created/modified matter).rc.change.after.acl in PUBLIC_ACLS — check whether the bucket ACL after apply is public.msg := ... — the violation message that appears as output.This input.resource_changes structure comes exactly from terraform show -json, so the policy directly reflects what Terraform will do.
Now let's put it all together. Generate the plan, export it to JSON, then evaluate with OPA:
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
opa eval \
--format pretty \
--data policy \
--input tfplan.json \
"data.terraform.deny"If there's a violation, the result is a list of messages:
[
"aws_s3_bucket.assets: public S3 bucket is forbidden by organizational policy.",
"aws_security_group_rule.ssh_world: SSH (22) open to 0.0.0.0/0 — FORBIDDEN",
"aws_instance.web: required tags are mandatory (missing: CostCenter, Team)"
]When there are no violations, the result is [] — the plan is safe to apply.
In the pipeline, this evaluation result becomes a gate: pass if deny is empty, fail if there's any violation. Here's an example of a simple gate script:
#!/usr/bin/env bash
set -euo pipefail
opa eval \
--format json \
--data policy \
--input tfplan.json \
"data.terraform.deny" > /tmp/deny.json
violations=$(jq '.result[0] | length' /tmp/deny.json)
if [ "$violations" -gt 0 ]; then
echo "::error::Infrastructure VIOLATES policy:"
jq -r '.result[0][]' /tmp/deny.json
exit 1
fi
echo "Policy gate: PASSED — plan is safe to apply"Important
Remember the rule from episode 13: terraform apply must not run from a local developer laptop in production. PaC works best when it's a mandatory stage in the CI/CD pipeline — right after plan, and only passing the policy gate before any manual approval mechanism.
The second rule, very useful for cost control: every VM must have standard tags (Environment, CostCenter, Team). Without tags, cloud cost burden can't be allocated to each team.
package terraform
import rego.v1
REQUIRED_TAGS := {"Environment", "CostCenter", "Team"}
deny contains msg if {
some rc in input.resource_changes
rc.type in {"aws_instance", "aws_eks_node_group", "aws_lb"}
rc.change.actions[_] != "delete"
missing_tags := REQUIRED_TAGS - object.keys(rc.change.after.tags)
count(missing_tags) > 0
msg := sprintf(
"%s: required tags are mandatory %v (missing: %v)",
[rc.address, REQUIRED_TAGS, missing_tags],
)
}The core logic is in the line missing_tags := REQUIRED_TAGS - object.keys(rc.change.after.tags). We compute the difference between the required tags and the tags the resource has; if anything remains, something is missing and a violation occurs.
The third rule targets one of the most common causes of attacks: SSH port 22 open to 0.0.0.0/0.
package terraform
import rego.v1
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_security_group_rule"
rc.change.actions[_] != "delete"
rc.change.after.from_port == 22
rc.change.after.cidr_blocks[_] == "0.0.0.0/0"
msg := sprintf("%s: SSH (port 22) open to 0.0.0.0/0 — FORBIDDEN", [rc.address])
}The expression rc.change.after.cidr_blocks[_] == "0.0.0.0/0" checks whether the CIDR list on the security group rule contains 0.0.0.0/0. If so, the rule immediately rejects the plan.
Tip
When developing policies, use opa test to write unit tests with JSON fixtures. Create a _test.rego file containing problematic input cases and clean input, then run opa test policy/. This ensures your own policies don't have bugs — because a misconfigured policy could silently let every plan "pass".
In the HashiCorp ecosystem (Terraform Cloud / HCP Terraform), policies are written in the Sentinel language. The concept: we define rules that evaluate to true (pass) or false (fail). Sentinel imports plan data via tfplan/v2, and Terraform Cloud runs it on every plan.
A Sentinel policy for the same rule (banning public S3 buckets):
import "tfplan/v2" as tfplan
# ACL values that violate the policy
public_acls = ["public-read", "public-read-write", "authenticated-read"]
# Get all S3 buckets to be created/modified
s3_buckets = filter tfplan.resource_changes as _, rc {
rc.mode is "managed" and
rc.type is "aws_s3_bucket" and
rc.change.actions contains "create"
}
# Main rule: there must be no bucket with a public ACL
main = rule {
all s3_buckets as _, rc {
rc.change.after.acl not in public_acls
}
}Note the structural similarity to Rego: both iterate resource_changes, both filter by type and action. The difference is that Sentinel uses rule/all/filter which are closer to general programming language style.
In Terraform Cloud, policies are organized into Policy Sets that can be linked to workspaces or the entire organization. Each policy has an enforcement level:
| Enforcement Level | Behavior |
|---|---|
soft-mandatory | Plan runs, but is flagged as a violation |
hard-mandatory | Plan is stopped until the violation is fixed |
advisory | Informational only, doesn't block anything |
Warning
For security rules, always use hard-mandatory. soft-mandatory is only suitable for advisory rules (e.g. naming standards). Rules like "public buckets are forbidden" or "SSH to 0.0.0.0/0 is forbidden" must force the plan to stop, not just warn.
package terraform
import rego.v1
PUBLIC_ACLS := {"public-read", "public-read-write", "authenticated-read"}
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_s3_bucket"
rc.change.actions[_] != "delete"
rc.change.after.acl in PUBLIC_ACLS
msg := sprintf("%s: public S3 bucket is forbidden.", [rc.address])
}Besides your own CI/CD pipeline, the managed IaC platforms we discussed in episode 14 already provide a place to enforce PaC without managing evaluation infrastructure:
| Platform | Policy Engine | How It Works |
|---|---|---|
| Terraform Cloud / HCP | Sentinel | Policy Sets + enforcement levels (hard-mandatory, soft-mandatory) |
| Spacelift | OPA (Rego) | Policy Packs installable per stack or globally |
| env0 | OPA / Rego | Policy as code at the organization level |
| Self-hosted CI/CD (GitHub Actions, GitLab CI) | OPA CLI | Gate scripts after terraform show -json |
The same pattern applies on every platform: plan generated → exported → evaluated → blocked if it violates. The only difference is where policies live and how the results are reported.
| Mistake | Symptom | Solution |
|---|---|---|
| Evaluating HCL code, not plan JSON | Policy passes even though the plan violates | Always evaluate the terraform show -json tfplan result |
Not filtering delete actions | Deleted resources get evaluated / wrongly blocked | Add rc.change.actions[_] != "delete" |
deny empty due to input errors | All plans silently pass | Write fixtures + opa test for every policy |
Making all rules soft-mandatory | Security rules ignored, apply still runs | Set hard-mandatory for security & cost rules |
| One global policy for all environments | Dev rules block prod or vice versa | Separate policy sets per environment |
A policy without sprintf/clear messages | Engineers confused why the plan is rejected | Include rc.address in the violation message |
| Not parameterizing policy variables | Threshold values scattered inside rules | Use policy constants/parameters |
In this episode 16 we learned that manual code review isn't enough to secure increasingly large infrastructure, so an automatic guard rail in the form of Policy as Code is needed. We understood the basic flow: terraform plan → terraform show -json → policy evaluation → apply only if it passes. We also practiced three important rules in the Rego language (OPA): banning public S3 buckets, requiring standard tags, and forbidding SSH port 22 open to 0.0.0.0/0. Finally, we saw how Sentinel works natively in Terraform Cloud with the hard-mandatory/soft-mandatory enforcement levels.
Key takeaways to bring home:
hard-mandatory), not just recommended.opa test so no gaps silently pass.With PaC in place, all infrastructure changes now pass automatic inspection before touching production. The defenses are layered: secrets are safe, state is safe, and change plans are verified.
Now it's time to assemble all the components we've learned into one complete whole. In the next episode 17 we'll discuss Provisioning Complex Cloud Infrastructure Stacks — a case study of building a complete multi-tier stack from scratch: a multi-AZ VPC, a Kubernetes cluster (EKS), a PostgreSQL database (RDS), and encrypted object storage. Stay excited!