Security

Enforce Kubernetes Policies with OPA Gatekeeper

Intermediate55 min to complete13 min readAugust 16, 2026Updated August 29, 2026

Quick answer

Install Gatekeeper, write a real ConstraintTemplate in Rego, scope it with a Constraint, and roll it out safely with dry-run mode before it ever rejects a real Pod.

intermediate · 55 min

Before you begin

  • A cluster you can use (kind or minikube is fine) and kubectl configured
  • Helm installed
  • Basic familiarity with Kubernetes admission control concepts
  • No Rego experience required — this tutorial teaches enough to be productive
OPA
Gatekeeper
Kubernetes
Policy as Code
Security
Admission Control
Rego

OPA Gatekeeper and Kyverno solve the same problem — blocking or mutating Kubernetes resources at admission time — in different ways. Gatekeeper is the CNCF-graduated original: policies are written in Rego, OPA's purpose-built policy language, which is more expressive for complex logic but has a steeper learning curve than plain YAML. This tutorial is for readers who've chosen the Rego path, or need to evaluate it against Kyverno's YAML-based approach covered in Enforcing Policy with Kyverno.

Gatekeeper itself is a validating (and optionally mutating) admission webhook — if you want the raw mechanics of how that webhook plumbing works under the hood, Write a Kubernetes Admission Webhook From Scratch covers it directly; this tutorial won't re-derive it.

What You'll Build

  • Gatekeeper installed via its official Helm chart
  • A ConstraintTemplate written in Rego that requires a team label on Pods
  • A Constraint that applies that template to the workloads namespace only
  • A confirmed rejection of a non-compliant Pod, and acceptance of a compliant one
  • The same policy rolled out safely first, using dry-run mode
  • A pointer to the community Gatekeeper policy library so you're not hand-writing everything

Step 1: Install Gatekeeper

Install via the official Helm chart:

bash
helm repo add gatekeeper https://open-policy-agent.github.io/gatekeeper/charts
helm repo update

helm install gatekeeper gatekeeper/gatekeeper \
  --namespace gatekeeper-system \
  --create-namespace

Confirm the controller and audit pods are running:

bash
kubectl -n gatekeeper-system get pods

Gatekeeper is built around two CRDs, and the split between them is the core concept to understand before writing anything:

  • ConstraintTemplate — defines a reusable policy, written in Rego, with a schema for its input parameters. Think of it as a function: it doesn't do anything on its own until you instantiate it.
  • Constraint — an instance of a ConstraintTemplate, scoped to specific resource kinds and namespaces, with specific parameter values filled in.

One ConstraintTemplate — "require these labels" — can back many Constraints: one requiring team on Pods in workloads, another requiring cost-center on Deployments in billing. You write the Rego logic once and reuse it by parameterizing Constraints, the same way you'd write one function and call it with different arguments instead of copy-pasting the function body.

Step 2: Write a ConstraintTemplate

Create required-labels-template.yaml. This defines a policy that rejects any object missing one or more labels named in its labels parameter:

yaml
1# required-labels-template.yaml
2apiVersion: templates.gatekeeper.sh/v1
3kind: ConstraintTemplate
4metadata:
5  name: k8srequiredlabels
6spec:
7  crd:
8    spec:
9      names:
10        kind: K8sRequiredLabels
11      validation:
12        openAPIV3Schema:
13          type: object
14          properties:
15            labels:
16              type: array
17              items:
18                type: string
19  targets:
20    - target: admission.k8s.gatekeeper.sh
21      rego: |
22        package k8srequiredlabels
23
24        violation[{"msg": msg}] {
25          provided := {label | input.review.object.metadata.labels[label]}
26          required := {label | label := input.parameters.labels[_]}
27          missing := required - provided
28          count(missing) > 0
29          msg := sprintf("you must provide labels: %v", [missing])
30        }

Walking through the Rego: provided is the set of label keys actually present on the object being reviewed (input.review.object is the raw resource, exactly as it would be admitted). required is the set of label keys the Constraint asks for, read from input.parameters.labels. missing is a set difference — every required label not in the provided set. If missing is non-empty, the violation rule fires and Gatekeeper rejects the request with msg.

kind: K8sRequiredLabels is the name of the CRD this template generates — that's what you'll reference when you create Constraints. spec.crd.spec.validation.openAPIV3Schema defines what a Constraint's parameters field is allowed to contain; here, just a labels array of strings.

Apply it:

bash
kubectl apply -f required-labels-template.yaml

Gatekeeper's controller reads this and dynamically registers the K8sRequiredLabels CRD — check it exists:

bash
kubectl get crd k8srequiredlabels.constraints.gatekeeper.sh

Step 3: Create a Constraint

The template alone enforces nothing. Create a Constraint that instantiates it, scoped to Pods in the workloads namespace, requiring the team label specifically:

yaml
1# require-team-label.yaml
2apiVersion: constraints.gatekeeper.sh/v1beta1
3kind: K8sRequiredLabels
4metadata:
5  name: pods-must-have-team-label
6spec:
7  enforcementAction: deny
8  match:
9    kinds:
10      - apiGroups: [""]
11        kinds: ["Pod"]
12    namespaces:
13      - "workloads"
14  parameters:
15    labels: ["team"]

match.kinds restricts this Constraint to core-group Pods — Gatekeeper won't even evaluate the Rego for anything else. match.namespaces scopes it to workloads only; if you wanted to enforce cluster-wide except for system namespaces, you'd use excludedNamespaces: ["kube-system", "gatekeeper-system"] instead. enforcementAction: deny is what actually blocks the request — you'll change this to dryrun in Step 5.

Apply it:

bash
kubectl create namespace workloads
kubectl apply -f require-team-label.yaml

Step 4: Test the Rejection

Try to create a Pod without the team label:

bash
kubectl run no-label-pod --image=nginx -n workloads

Gatekeeper rejects it at admission time, before it ever reaches etcd:

Error from server (Forbidden): admission webhook "validation.gatekeeper.sh" denied the request: [pods-must-have-team-label] you must provide labels: {"team"}

Now create one with the label:

bash
kubectl run has-label-pod --image=nginx -n workloads --labels="team=platform"

This one is admitted normally:

pod/has-label-pod created

Same Constraint, same Rego, different outcome based purely on whether the required label is present — that's the policy doing exactly what it says.

Step 5: Roll Out Safely with Dry-Run Mode

Setting enforcementAction: deny straight away on a real cluster is how you find out — at 2am — that half your CI pipeline creates Pods without labels. Before enforcing anything against production traffic, switch the Constraint to dry-run mode, which evaluates every matching request and records violations without blocking anything:

yaml
1# require-team-label.yaml
2apiVersion: constraints.gatekeeper.sh/v1beta1
3kind: K8sRequiredLabels
4metadata:
5  name: pods-must-have-team-label
6spec:
7  enforcementAction: dryrun   # was: deny
8  match:
9    kinds:
10      - apiGroups: [""]
11        kinds: ["Pod"]
12    namespaces:
13      - "workloads"
14  parameters:
15    labels: ["team"]
bash
kubectl apply -f require-team-label.yaml

In dry-run mode, creating no-label-pod again succeeds — nothing is blocked. But Gatekeeper's audit controller still evaluates the Constraint against existing cluster state on a periodic interval and records what would have been rejected in the Constraint's own status:

bash
kubectl get k8srequiredlabels pods-must-have-team-label -o yaml

Look at status.violations:

yaml
1status:
2  auditTimestamp: "2026-08-16T10:04:22Z"
3  totalViolations: 1
4  violations:
5    - kind: Pod
6      name: no-label-pod
7      namespace: workloads
8      message: 'you must provide labels: {"team"}'

This is the single most useful workflow for adopting Gatekeeper on a real cluster: leave every new Constraint in dryrun, let the audit controller run for a few cycles (the default audit interval is 60 seconds, configurable via the --audit-interval flag on the controller), review status.violations across your fleet, fix or exempt what shows up, and only then flip enforcementAction to deny. Skipping straight to deny is how a policy rollout becomes an incident.

Step 6: Don't Hand-Write Everything — Use the Policy Library

The K8sRequiredLabels template above is a reasonable first policy to write yourself because it's small enough to fully understand. For anything beyond that — requiring resource limits, blocking privileged containers, restricting image registries to an allowlist — start from the community-maintained Gatekeeper policy library instead of writing Rego from scratch. It ships ConstraintTemplates for the policies almost every cluster ends up needing, already tested against Gatekeeper's own CI, with example Constraints you adapt rather than invent. Reach for hand-written Rego, like the template above, only when a policy is specific enough to your org that nothing in the library covers it.

Verify It Worked

Confirm both CRDs are registered and active:

bash
kubectl get constrainttemplates
kubectl get constraints

You should see k8srequiredlabels in the first list and pods-must-have-team-label (kind K8sRequiredLabels) in the second. Set enforcement back to deny — edit require-team-label.yaml from Step 5, flipping enforcementAction back:

yaml
# require-team-label.yaml
spec:
  enforcementAction: deny   # back from dryrun

Then re-run the Step 4 rejection to reconfirm it's actually blocking, not just auditing:

bash
kubectl apply -f require-team-label.yaml
kubectl run no-label-pod-2 --image=nginx -n workloads
# admission webhook "validation.gatekeeper.sh" denied the request

If that command is rejected with the same you must provide labels message, the ConstraintTemplate, the Constraint, and enforcement are all wired correctly end to end.

Where to Go Next

We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.

Struggling with this in production?

We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.