

Infrastructure as Code (IaC) solves the provisioning problem. It doesn't solve the governance problem.
You can version your Terraform configuration, run it in a pipeline, review every pull request — and still deploy an S3 bucket with public access, a VM with no encryption, or a resource that exceeds your cost budget. Nothing in the standard IaC workflow checks for those things. The reviewer has to know what to look for. And they won't catch it every time.
Policy as Code changes that. Instead of relying on reviewers, you write the rules as code. They run automatically on every plan, the same way your tests run on every commit.
Open Policy Agent (OPA) is what most platform teams use to implement it.
At a glance
Open Policy Agent (OPA) is a CNCF-graduated, open source policy engine (Apache 2.0) that evaluates Terraform plan JSON against rules written in Rego. Current version: v1.14.0 (February 2026). OPA 1.0 shipped December 2024 — the first stable API release, with breaking syntax changes to Rego. The standard Terraform integration: export the plan as JSON with
terraform show -json, evaluate with Conftest v0.67.1. Policies run beforeterraform applyand fail the pipeline on violations.
What is Policy as Code?
Without Policy as Code, governance lives in runbooks, Slack threads, and institutional knowledge. A senior engineer catches the public S3 bucket in code review. A junior engineer doesn't. Compliance gaps surface in audits, not before deployment.
The pattern we see most often: IaC covers the greenfield work, but there's no automated check ensuring it meets your security baseline. A policy that says "all S3 buckets must have server-side encryption" has to fire the same way every time, across every team, regardless of who wrote the Terraform. That's what Policy as Code does.
OPA is the engine that evaluates those rules. Terraform generates the plan. Your CI/CD pipeline connects them. If the plan violates a policy, the pipeline fails before terraform apply runs.
What is Open Policy Agent (OPA)?
OPA is an open-source, general-purpose policy engine maintained by the Cloud Native Computing Foundation (CNCF). It reached CNCF graduation in 2021. Version 1.0 shipped in December 2024 — the first stable API guarantee in the project's history, and a breaking change to the policy language syntax.
OPA evaluates structured data (JSON) against rules written in Rego, its purpose-built policy language. Terraform can export its plan as JSON, which means OPA can evaluate any planned infrastructure change against any rule you write.
It's not Terraform-specific. OPA also handles Kubernetes admission control, API authorization, and microservice access control. For Terraform, the integration point is the plan file.
Current version: OPA v1.14.0 (released February 2026)
License: Apache 2.0
Official docs: openpolicyagent.org/docs/latest
What changed in OPA 1.0
OPA 1.0 shipped December 20, 2024. Every tutorial written before that uses syntax that's now deprecated. If you're working from older guides — even well-known ones — you'll need to update your policies.
Three changes matter for Terraform use:
if is now required in rule heads. Previously optional, it's now mandatory for all rule bodies.
contains is required for multi-value rules. Partial set rules — the pattern used for deny policies — need contains to distinguish them from single-value rules.
import rego.v1 replaces import future.keywords. Add this at the top of every policy file. It enables all v1 syntax and acts as the compatibility bridge between old and new.
Here's the same policy written in both versions:
# v0 (deprecated) — still accepted with --v0-compatible flag
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket"
not resource.change.after.server_side_encryption_configuration
msg := sprintf("S3 bucket '%v' missing server-side encryption", [resource.address])
}
# v1 (current) — use this for all new policies
deny contains msg if {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket"
not resource.change.after.server_side_encryption_configuration
msg := sprintf("S3 bucket '%v' missing server-side encryption", [resource.address])
}
Migrating existing policies: Run opa fmt --rego-v1 <policy.rego> to auto-convert syntax. Run opa check --rego-v1 to validate before switching. The --v0-compatible flag lets OPA 1.x run old policies without rewriting them immediately — useful as a bridge, not a permanent state.
How OPA works with Terraform
The workflow is three steps: generate the plan, evaluate it, block or allow.
# Step 1: export the plan as JSON
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
# Step 2: evaluate with OPA directly...
opa eval --data policies/ --input tfplan.json "data.terraform.deny"
# ...or with Conftest (recommended for CI — covered below)
conftest test tfplan.json --policy policies/
OPA never touches your cloud provider. It reads the plan JSON — a static snapshot of what Terraform intends to do — and returns whether your policies allow it. No AWS credentials needed for evaluation itself.
What the plan JSON looks like
It helps to know what structure OPA is reading before you write rules against it. The key field is resource_changes:
{
"resource_changes": [
{
"address": "aws_s3_bucket.logs",
"type": "aws_s3_bucket",
"change": {
"actions": ["create"],
"before": null,
"after": {
"bucket": "my-logs-bucket",
"tags": {
"environment": "prod"
}
}
}
}
]
}
Your Rego rules iterate over resource_changes, filter by type, and inspect change.after. One thing to watch: change.after is null for destroy operations — always check change.actions before reading change.after to avoid unexpected behavior.
Writing policies in Rego v1
Rego is declarative and logic-based — closer to Datalog than to Python or HashiCorp Configuration Language (HCL). The mental model: you define rules that produce results when conditions are true. Rather than writing a function that checks whether a bucket is encrypted, you write a rule that adds a message to the deny set when it isn't.
[_] is Rego's wildcard iterator. resource := input.resource_changes[_] means "bind resource to each element in the array." If you're coming from Python or Go, this is the part that takes a moment to internalize.
The structure of a deny rule
package terraform.analysis
import rego.v1
deny contains msg if {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket"
resource.change.actions[_] == "create"
not resource.change.after.server_side_encryption_configuration
msg := sprintf(
"S3 bucket '%v' must have server-side encryption enabled",
[resource.address]
)
}
What each line does:
package terraform.analysis— namespaces the policy. Convention for Terraform policies.import rego.v1— required for v1 syntax.deny contains msg if { ... }— a partial set rule. Every time the body evaluates to true,msggets added to thedenyset.input.resource_changes[_]— iterates over every planned change.resource.change.actions[_] == "create"— scoped to creates only. Omit this if you also want to catch updates.not resource.change.after.server_side_encryption_configuration— the condition: encryption config absent.sprintf(...)— human-readable error message with the resource address.
Tagging enforcement (AWS)
Tagging policies are the most commonly enforced OPA rule in practice. Cost allocation, ownership, and compliance all depend on consistent tags across resources.
package terraform.analysis
import rego.v1
required_tags := {"owner", "environment", "cost-center"}
deny contains msg if {
resource := input.resource_changes[_]
resource.change.actions[_] in {"create", "update"}
missing := required_tags - {tag | resource.change.after.tags[tag]}
count(missing) > 0
msg := sprintf(
"Resource '%v' is missing required tags: %v",
[resource.address, missing]
)
}
Note the set comprehension: {tag | resource.change.after.tags[tag]} builds the set of tags that are present. Subtracting from required_tags gives you the missing ones.
Instance type restriction (cost control)
package terraform.analysis
import rego.v1
allowed_instance_types := {"t3.micro", "t3.small", "t3.medium"}
deny contains msg if {
resource := input.resource_changes[_]
resource.type == "aws_instance"
resource.change.actions[_] in {"create", "update"}
instance_type := resource.change.after.instance_type
not instance_type in allowed_instance_types
msg := sprintf(
"EC2 instance '%v' uses instance type '%v'. Allowed types: %v",
[resource.address, instance_type, allowed_instance_types]
)
}
Public access block (S3 security)
package terraform.analysis
import rego.v1
deny contains msg if {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket_public_access_block"
resource.change.actions[_] in {"create", "update"}
resource.change.after.block_public_acls == false
msg := sprintf(
"S3 bucket public access block '%v' must have block_public_acls = true",
[resource.address]
)
}
Azure resource group naming convention
package terraform.analysis
import rego.v1
deny contains msg if {
resource := input.resource_changes[_]
resource.type == "azurerm_resource_group"
resource.change.actions[_] == "create"
name := resource.change.after.name
not startswith(name, "rg-")
msg := sprintf(
"Resource group '%v' must follow naming convention: rg-<name>",
[resource.address]
)
}
Testing policies with opa test
Untested policies drift. You update a rule, introduce a bug, and don't find out until a bad deployment gets through. OPA has a built-in test runner — use it from the start.
# policies/s3_test.rego
package terraform.analysis_test
import rego.v1
# Bucket without encryption should be denied
test_deny_unencrypted_bucket if {
result := data.terraform.analysis.deny with input as {
"resource_changes": [{
"address": "aws_s3_bucket.example",
"type": "aws_s3_bucket",
"change": {
"actions": ["create"],
"after": {
"bucket": "my-bucket"
# no server_side_encryption_configuration key
}
}
}]
}
count(result) == 1
}
# Bucket with encryption should pass
test_allow_encrypted_bucket if {
result := data.terraform.analysis.deny with input as {
"resource_changes": [{
"address": "aws_s3_bucket.example",
"type": "aws_s3_bucket",
"change": {
"actions": ["create"],
"after": {
"bucket": "my-bucket",
"server_side_encryption_configuration": [{
"rule": [{"apply_server_side_encryption_by_default": [{"sse_algorithm": "AES256"}]}]
}]
}
}
}]
}
count(result) == 0
}
Run all tests:
opa test policies/
The iteration loop with opa test is fast — faster than running a full Conftest job. Write the test first, make it pass, then move on. When a policy update breaks an existing test, you catch it before CI does.
Running policies with Conftest
Conftest is the standard CLI for running OPA policies against Terraform plans in CI/CD. Current version: v0.67.1 (March 2026). It wraps OPA's evaluation logic and adds CI-friendly output, multiple format support, and a simpler invocation than raw opa eval.
Conftest's rule naming is convention-based: it looks for rules named deny, warn, and violation. If your rule is named anything else, Conftest won't pick it up. Name your rules accordingly.
Install:
# macOS
brew install conftest
# Linux
curl -L https://github.com/open-policy-agent/conftest/releases/download/v0.67.1/conftest_0.67.1_Linux_x86_64.tar.gz | tar xz
Recommended project structure:
.
├── main.tf
├── variables.tf
├── outputs.tf
└── policies/
├── cost.rego # instance types, region restrictions
├── cost_test.rego
├── security.rego # encryption, public access, IAM
├── security_test.rego
├── tagging.rego # required tags by resource type
└── tagging_test.rego
Organize by domain, not by resource type. A cost.rego that enforces instance limits and cost tags is easier to maintain than one aws_instance.rego with everything in it.
If you're tired of typing --policy policies/ on every invocation, add a conftest.toml to your project root:
# conftest.toml
[runner]
policy = "policies"
input = "tfplan.json"
Then conftest test picks up both the policy directory and input file automatically.
Run against a Terraform plan:
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
conftest test tfplan.json --policy policies/
Output on failure:
FAIL - tfplan.json - terraform.analysis - S3 bucket 'aws_s3_bucket.logs' must have server-side encryption enabled
FAIL - tfplan.json - terraform.analysis - Resource 'aws_instance.web' is missing required tags: {"cost-center", "owner"}
2 tests, 0 passed, 0 warnings, 2 failures, 0 exceptions
Output on pass:
2 tests, 2 passed, 0 warnings, 0 failures, 0 exceptions
Conftest exits with a non-zero code on failure, which causes CI to fail automatically.
CI/CD integration with GitHub Actions
The workflow below runs on every pull request that touches .tf files or policies. It generates a Terraform plan, exports it as JSON, runs Conftest against your policy directory, and blocks the merge if any deny rules fire. The plan JSON is uploaded as an artifact so you can inspect exactly what was evaluated.
# .github/workflows/terraform-policy-check.yml
name: Terraform Policy Check
on:
pull_request:
paths:
- '**.tf'
- 'policies/**'
jobs:
policy-check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.10.0"
- name: Setup Conftest
run: |
curl -L https://github.com/open-policy-agent/conftest/releases/download/v0.67.1/conftest_0.67.1_Linux_x86_64.tar.gz | tar xz
sudo mv conftest /usr/local/bin/
- name: Terraform Init
run: terraform init -backend=false
- name: Terraform Plan
run: |
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
env:
# Add provider credentials here — e.g. AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
# or use OIDC: https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Run OPA Policy Check
run: conftest test tfplan.json --policy policies/
- name: Upload Plan for Review
if: always()
uses: actions/upload-artifact@v4
with:
name: terraform-plan
path: tfplan.json
Two notes on terraform init -backend=false: this works for plans that don't read remote state. If your configuration uses data sources that query live infrastructure (e.g., data.aws_vpc.main), you'll need a real backend configured or your plan will fail. For those cases, either mock the data sources or use a full backend with read-only credentials in CI.
This workflow blocks the merge when policy violations occur. The plan JSON is uploaded as an artifact regardless of outcome — useful when you want to review exactly what was planned.
OPA vs. other policy engines
Most Terraform teams evaluate OPA alongside HashiCorp Sentinel and Checkov. They solve overlapping problems but make different trade-offs.
| OPA + Conftest | HashiCorp Sentinel | Checkov | |
|---|---|---|---|
| Language | Rego | Sentinel DSL | Python |
| Scope | Any JSON input (Terraform, Kubernetes, APIs) | HashiCorp stack only | Terraform, CloudFormation, Kubernetes, ARM |
| Where it runs | CI/CD, local, anywhere | Inside HCP Terraform/Enterprise only | CI/CD, local |
| Cost | Open source, free | HCP Terraform Enterprise | Open source, free |
| Policy testing | Built-in (opa test) |
Built-in | Limited |
| Kubernetes support | Yes (OPA Gatekeeper) | No | Yes |
| Rego learning curve | Moderate | Low (simpler DSL) | None (pre-built policies) |
| Best for | Multi-cloud governance, complex policies | Terraform-only, HCP Terraform users | Fast adoption, pre-built security rules |
Rego has a learning curve. The first time you write a non-trivial policy, iteration can be slow. That's the honest trade-off for getting a general-purpose engine that works across Kubernetes admission control, API authorization, and Terraform governance with the same policy language.
When to use OPA: You need governance across Terraform and other systems. You want full control over policy logic. You can invest a few days in learning Rego.
When to use Sentinel: Your team is fully HCP Terraform-native and doesn't need Kubernetes or non-HashiCorp governance.
When to use Checkov: You want fast adoption with pre-built security rules and don't need custom business logic.
OPA and Checkov aren't mutually exclusive — teams sometimes use Checkov for pre-built CIS Benchmark rules and OPA for custom policy logic specific to their environment.
OPA with OpenTofu
OPA works identically with OpenTofu as it does with Terraform. Both generate the same plan JSON format. Every policy in this guide works with OpenTofu without modification.
# OpenTofu workflow — identical to Terraform
tofu plan -out=tfplan
tofu show -json tfplan > tfplan.json
conftest test tfplan.json --policy policies/
For teams migrating from Terraform to OpenTofu following HashiCorp's license change, this is worth knowing: your OPA policies are portable. Nothing needs to change on the policy side.
Using OPA with env zero
Running Conftest in CI is a good start. The problem is that CI is optional — engineers can still run terraform apply locally or through other paths, and those applies don't go through your pipeline.
env zero enforces policies at the platform layer, across every deployment path. You upload your Rego policies once, assign them to projects or environments, and env zero evaluates the Terraform plan before every apply — whether the apply was triggered from the UI, the CLI, the API, or an automated pipeline. Engineers don't have an escape hatch.
For teams managing 50+ environments, that distinction matters. "We have policies" and "our policies actually run" are different statements.
→ See how env zero handles policy enforcement
OPA policy best practices
Deploy as warn before you enforce. Conftest supports three rule types: deny, warn, and violation. Roll out new policies as warn first — you'll see how many violations exist across your infrastructure without blocking anyone's work. Switch to deny once you've resolved the backlog and set expectations with your teams.
We've seen this go wrong: a team rolls out a tagging policy, half their existing resources fail it on day one, and they've just blocked every deploy across the organization. A warn-first rollout with a clear enforcement date avoids that.
# Warn first — doesn't fail CI
warn contains msg if {
resource := input.resource_changes[_]
resource.type == "aws_instance"
not resource.change.after.tags.owner
msg := sprintf("Instance '%v' is missing 'owner' tag (will be required after 2026-05-01)", [resource.address])
}
Test every policy. Policies without tests drift silently. Run opa test policies/ as part of your CI pipeline alongside Conftest — not just locally.
Version your policy library. Store policies in a dedicated repository with tagged releases. When a policy change breaks a legitimate deployment, you can roll back to the previous version and investigate.
Use the Rego Playground for iteration. Paste a Terraform plan JSON and test your policy logic interactively — far faster than running the full CI loop while writing a new rule. The plan JSON you captured earlier with terraform show -json works directly.
What's next
If you're starting from scratch, build a tagging policy first. It covers the most common violation type, teaches you the Rego data model with forgiving failures (missing tags don't break infrastructure, they just shouldn't be deployed unchecked), and gives you immediate signal on how widespread your violations actually are. Run it as warn for two weeks before switching to deny.
Once you have a working tagging policy in CI, the natural progression is:
- Expand the rule library — the OPA GitHub repo has production-ready policies for CIS Benchmarks, SOC 2, and PCI DSS
- Extend to Kubernetes — OPA Gatekeeper applies the same Rego-based approach to admission control; the policy language you learned here carries over
- Centralize distribution — Conftest policy bundles let you share policies across teams via OCI registries rather than copy-pasting files between repositories
Frequently asked questions
Does OPA work with HCP Terraform (Terraform Cloud)?
Yes. HCP Terraform has native OPA support for policy enforcement — you can upload Rego policies directly through the UI or API and they'll run on every workspace apply. Sentinel is HashiCorp's first-party policy language and integrates more deeply with HCP Terraform's access controls, but OPA is a fully supported alternative. See HashiCorp's documentation on defining OPA policies for HCP Terraform.
What's the difference between OPA and Sentinel?
Sentinel is HashiCorp's policy language — it only runs inside HCP Terraform and Vault Enterprise. OPA is open source, runs anywhere, and covers non-HashiCorp systems like Kubernetes and custom APIs. If you're fully committed to the HashiCorp stack, Sentinel is simpler. If you need governance across more than one system, OPA has a broader surface area.
Can I use OPA without Conftest?
Yes. The opa eval command runs policies directly: opa eval --data policies/ --input tfplan.json "data.terraform.deny". Conftest wraps this with CI-friendly output formatting, exit codes, and multi-file support. For most teams running policies in a pipeline, Conftest is easier. For advanced use cases — custom rule namespaces, non-standard output formats — using OPA directly gives you more control.
Do OPA policies work with OpenTofu?
Yes, without modification. OpenTofu and Terraform produce identical plan JSON schemas. Every policy and every Conftest invocation in this guide works the same way with tofu commands substituted for terraform.
Do I need to rewrite my existing OPA policies for v1?
Not immediately. Run opa check --rego-v1 against your existing policies to see what needs updating. Use opa fmt --rego-v1 to auto-convert most of it. The --v0-compatible flag on OPA 1.x lets you run old syntax while you migrate. The changes are mechanical — if and contains keywords in rule heads — rather than semantic.
Try it with env zero
Running Conftest in CI gets you coverage on the pull request path. It doesn't cover engineers running terraform apply locally, applying through other pipelines, or using the CLI directly.
env zero enforces your Rego policies at the platform layer — before every apply, regardless of where it's triggered. Upload your policies once, assign them to projects or environments, and env zero handles the rest. Engineers don't have an escape hatch. Local applies get caught too. If your team uses Pulumi rather than Terraform, Pulumi CrossGuard fills the same role as Conftest — our Pulumi guide covers how CrossGuard fits into the Pulumi platform and how env zero enforces OPA policies across both frameworks.
For platform teams managing 50+ environments, "our policies actually run everywhere" is a different statement than "we have a Conftest job in CI."
Start a free trial or book a demo to see policy enforcement in a live env zero environment.
References
- Open Policy Agent — Official Documentation
- OPA Terraform Integration Guide
- OPA 1.0 Release Notes
- Upgrading to OPA v1.0
- Rego v1 Compatibility
- Conftest Official Documentation
- Conftest GitHub — Releases
- HashiCorp Developer — Define OPA Policies for HCP Terraform
- OPA Playground (Rego Playground)
- CNCF — Open Policy Agent Project
- OpenTofu — Official Site
- Spacelift — OPA with Terraform Examples
- Scalr — OPA and Terraform Comprehensive Guide

.webp)
