Everyone Thinks OPA Is a Kubernetes Thing
You’ve probably heard of Open Policy Agent in the context of admission controllers. Someone on your team set up Gatekeeper or Kyverno, it blocked a deployment because someone pushed a container with :latest, and now everyone’s vaguely afraid of it. That’s fine. That’s a valid use case.
But OPA is way more than that. It’s a general-purpose policy engine that speaks one language — Rego — and it doesn’t care if your input is a Kubernetes manifest, a Terraform plan, a Dockerfile, or a raw HTTP request. If you can describe it as JSON, OPA can evaluate a policy against it.
Here’s the thing: your Terraform is probably full of untagged resources. Your CI pipeline probably lets FROM node:latest slide through. Your internal HTTP APIs probably have zero enforcement beyond “it authenticated.” These are policy problems, and OPA solves all of them.
Let’s fix that.
The Rego Mental Model (Actually Understand It)
Rego is not a procedural language. It’s a query language built on Datalog. If you approach it like Python or bash, you’ll be confused and angry by day two.
The key insight: rules in Rego evaluate to true (or a value) when their conditions are satisfied. If the conditions aren’t met, the rule is undefined — not false, undefined. This matters because the OPA decision is based on what rules evaluate to, not what they “return.”
Here’s the simplest possible policy:
package docker.images
# deny is a set — each match adds a message# (OPA 1.0+ requires the `contains` and `if` keywords for partial set rules)deny contains msg if { input.image == "latest" msg := "Do not use :latest tags. Pin your images."}
deny contains msg if { endswith(input.image, ":latest") msg := sprintf("Image %v uses :latest. Pin it.", [input.image])}You evaluate this by passing JSON as input. OPA iterates all matching rules and collects the resulting set. If deny is empty, the policy passes. If it has entries, it fails — and you get human-readable reasons.
This pattern (deny set with messages) is the bread-and-butter of OPA policies. Learn it. Use it everywhere.
Use Case 1: Block :latest Tags in CI
Your CI pipeline runs before images are built or deployed. This is the right place to enforce image tag hygiene. OPA’s conftest tool makes this trivial.
Install conftest:
brew install conftest# orgo install github.com/open-policy-agent/conftest@latestPut your policy in policy/docker.rego:
package main
deny contains msg if { input.Stages[_].From.Image == "latest" msg := "Base image uses :latest — pin to a digest or version tag."}
deny contains msg if { from := input.Stages[_].From.Image endswith(from, ":latest") msg := sprintf("Image '%v' is pinned to :latest. Use a real version.", [from])}
deny contains msg if { from := input.Stages[_].From.Image not contains(from, ":") msg := sprintf("Image '%v' has no tag at all. Same as :latest. Pin it.", [from])}Then run it against a Dockerfile:
conftest test Dockerfile --policy policy/Drop this in your GitHub Actions or GitLab CI:
- name: OPA policy check (Dockerfile) run: | conftest test Dockerfile --policy policy/If the Dockerfile has FROM node:latest, the build fails with a clear message. No more mystery latest images in production.
Use Case 2: Enforce Tags on Terraform Resources
Every AWS resource in your account is either tagged or it’s a mystery box. Cost attribution, environment identification, team ownership — all of it depends on tags. Terraform lets you forget them. OPA reminds you.
conftest parses Terraform HCL directly (or you can point it at a terraform plan JSON output, which is more reliable for complex setups).
package main
required_tags := {"Environment", "Team", "CostCenter"}
# Check resources that support tagsresource_types_with_tags := { "aws_instance", "aws_s3_bucket", "aws_rds_cluster", "aws_elasticache_cluster",}
deny contains msg if { resource := input.resource[resource_type][resource_name] resource_types_with_tags[resource_type] required_tag := required_tags[_] not resource.tags[required_tag] msg := sprintf( "Resource '%v' (%v) is missing required tag: %v", [resource_name, resource_type, required_tag] )}
deny contains msg if { resource := input.resource[resource_type][resource_name] resource_types_with_tags[resource_type] not resource.tags msg := sprintf( "Resource '%v' (%v) has no tags block at all.", [resource_name, resource_type] )}Run against your Terraform directory:
conftest test main.tf --policy policy/ --parser hcl2Or against a plan file (more accurate — catches computed values):
terraform plan -out=tfplan.binaryterraform show -json tfplan.binary > tfplan.jsonconftest test tfplan.json --policy policy/This is the kind of thing that, without automation, gets enforced via “we ask nicely in PRs and hope.” With OPA in CI, it’s just a failing check. Way less drama.
Use Case 3: HTTP API Authorization via Envoy
OPA has a dedicated integration for Envoy’s external authorization filter. You run OPA as a sidecar or standalone service, and Envoy asks it “should I allow this request?” before proxying it.
This is more involved to set up, but the policy itself is straightforward:
package envoy.authz
import input.attributes.request.http as http_request
# Default denydefault allow := false
# Allow health checks without authallow if { http_request.path == "/health" http_request.method == "GET"}
# Allow if valid bearer token and correct roleallow if { token := bearer_token claims := io.jwt.decode(token)[1] claims.role == "admin"}
allow if { token := bearer_token claims := io.jwt.decode(token)[1] http_request.method == "GET" claims.role == "reader"}
# Deny with reason for observabilitydeny contains msg if { not allow msg := sprintf( "Request to %v %v denied. Token missing required role.", [http_request.method, http_request.path] )}
bearer_token := token if { v := http_request.headers.authorization startswith(v, "Bearer ") token := substring(v, 7, -1)}Run OPA with the Envoy plugin enabled:
opa run --server \ --set plugins.envoy_ext_authz_grpc.addr=:9191 \ --set plugins.envoy_ext_authz_grpc.path=envoy/authz/allow \ policy/envoy_authz.regoYour Envoy config points ext_authz at localhost:9191, and every request gets policy-checked before it hits your service. No more “well the JWT was valid so we let it through” — you can enforce fine-grained role checks at the proxy layer without touching application code.
Use Case 4: Kubernetes Admission (Yes, This Too)
OPA’s Gatekeeper is the OG Kubernetes integration. You define ConstraintTemplate (which wraps a Rego policy) and Constraint resources (which instantiate the policy with parameters). It’s more boilerplate than raw OPA, but it integrates natively with kubectl.
A basic constraint template blocking privileged containers (Gatekeeper’s constraint framework still accepts the older violation[...] { } set syntax in templates, so no contains/if keywords needed here):
apiVersion: templates.gatekeeper.sh/v1kind: ConstraintTemplatemetadata: name: k8sdenyprivilegedspec: crd: spec: names: kind: K8sDenyPrivileged targets: - target: admission.k8s.gatekeeper.sh rego: | package k8sdenyprivileged
violation[{"msg": msg}] { container := input.review.object.spec.containers[_] container.securityContext.privileged == true msg := sprintf( "Container '%v' is privileged. That's a no.", [container.name] ) }
violation[{"msg": msg}] { container := input.review.object.spec.initContainers[_] container.securityContext.privileged == true msg := sprintf( "Init container '%v' is privileged. Still a no.", [container.name] ) }Then instantiate it:
apiVersion: constraints.gatekeeper.sh/v1beta1kind: K8sDenyPrivilegedmetadata: name: deny-privileged-containersspec: match: kinds: - apiGroups: [""] kinds: ["Pod"]Testing Rego with opa test
Untested policies are promises, not guarantees. OPA has a built-in test framework that’s genuinely pleasant to use.
package docker.images
test_deny_latest_explicit if { deny["Image 'node:latest' uses :latest. Pin it."] with input as { "image": "node:latest" }}
test_deny_latest_bare if { deny[_] with input as { "image": "ubuntu" # no tag = :latest }}
test_allow_pinned if { count(deny) == 0 with input as { "image": "node:20.11.1-alpine3.19" }}
test_allow_digest if { count(deny) == 0 with input as { "image": "node@sha256:abc123def456" }}Run them:
opa test policy/ -vYou should see:
PASS: 4/4Write tests before you deploy policies to production. Finding out your Rego has an edge case at 2 AM because Gatekeeper rejected a critical deployment is a special kind of painful.
OPA Bundles: Distributing Policies at Scale
If you’re managing policies across multiple services, clusters, or environments, you need bundles. A bundle is a tarball of .rego files and data documents that OPA can pull from an HTTP endpoint or S3/R2 bucket.
Build and sign a bundle:
# Build the bundleopa build policy/ -o bundle.tar.gz
# Configure OPA to pull from your bundle servercat > opa-config.yaml << 'EOF'bundles: main: resource: /bundles/main/bundle.tar.gz polling: min_delay_seconds: 60 max_delay_seconds: 120services: - name: bundle-server url: https://bundles.your-company.internalEOF
opa run --server --config-file opa-config.yamlNow every OPA instance in your fleet pulls the same policies. Update the bundle, push it to the server, and all agents pick it up within 2 minutes. No kubectl apply, no redeployment — just policy-as-actual-code with a real distribution mechanism.
Rego vs. Kyverno vs. CEL: Pick Your Fighter
These three come up constantly in the same conversations, and they’re not really competing — they’re serving different scopes.
Kyverno is YAML-native and Kubernetes-only. If your entire policy concern is “I want to enforce things in my cluster and I don’t want to learn Rego,” Kyverno is genuinely good. You write policies in YAML, it integrates cleanly with GitOps workflows, and it handles mutation (rewriting manifests) more elegantly than Gatekeeper. The tradeoff: it can’t do anything outside Kubernetes.
CEL (Common Expression Language) is what modern Kubernetes admission is moving toward with ValidatingAdmissionPolicy (GA in 1.30). It’s fast, embedded directly in the API server, no sidecar needed. If you’re on a recent cluster and only care about admission control, CEL is worth learning. The syntax is more familiar (Go-ish) but the expressiveness is limited compared to Rego.
OPA/Rego wins when you need the same policy logic to run in multiple places — CI, Terraform, HTTP APIs, and Kubernetes all at once. Rego is more complex to learn, but you write it once and deploy it everywhere. That’s the actual value proposition.
Honestly? Use Kyverno if it’s just K8s. Use CEL for simple admission on modern clusters. Reach for OPA when policy spans your whole platform.
When NOT to Bother
Here’s the honest take: OPA has a learning curve, operational overhead (you’re running a service), and non-trivial Rego complexity for anything beyond basic patterns. Don’t set it up if:
- You’re a team of two with one environment and no compliance requirements
- Your “policies” are basically “don’t do obviously dumb things” and PR review handles it
- You’re already on Kyverno for K8s and have no off-cluster policy needs
- You’d spend more time maintaining policies than they’d actually save
The ROI on OPA kicks in when you have multiple teams, multiple environments, and policies that need to be consistent across more than just your cluster. If that’s not you yet, a good linter and a code review culture will take you further with less overhead.
But when you do hit that scale? Rego is waiting. And it’s actually pretty good.
The Short Version
OPA and Rego are a general-purpose policy engine, not just a Kubernetes admission controller. The conftest tool makes it trivially easy to add policy checks to CI for Dockerfiles, Terraform plans, and any other YAML/JSON artifact. The Envoy integration brings the same logic to HTTP layer authorization. Gatekeeper handles admission in K8s, but Kyverno or CEL might be the better fit if you’re not going cross-platform.
Write policies as code, test them with opa test, distribute them with bundles, and stop enforcing rules via PR comments. Your future self — the one who doesn’t have to explain why prod has 300 untagged resources — will thank you.