OPA Gatekeeper is a Kubernetes admission controller built on Open Policy Agent. It checks every object sent to the API server against policies you write in the Rego language and rejects the ones that break them, for example namespaces without an owner label, privileged pods or images from unknown registries. In this tutorial you will install Gatekeeper with Helm, write three policies, audit the resources that already exist in dryrun mode, and then switch the policies to enforcement.

Prerequisites

To follow this tutorial you need:

  • A Kubernetes cluster running a currently supported version, such as a CubePath managed Kubernetes cluster or any other conformant cluster.
  • kubectl configured with cluster-admin permissions.
  • Helm 3 installed on the machine where you run kubectl.
  • jq, used to read audit results (sudo apt install -y jq on Ubuntu).

Use a test cluster first. An incorrect policy in deny mode can block deployments cluster-wide.

How Gatekeeper policies work

Gatekeeper splits a policy into two objects:

  • A ConstraintTemplate contains the Rego logic and defines a new resource kind, for example K8sRequiredLabels, with the parameters it accepts.
  • A Constraint is an instance of that kind. It says which resources the policy applies to (match), with which parameters, and what happens on a violation (enforcementAction).

enforcementAction accepts three values:

ValueEffect on new requestsReported by audit
dryrunAllowedYes
warnAllowed, kubectl prints a warningYes
denyRejectedYes

Besides checking new requests, Gatekeeper's audit controller periodically evaluates existing resources and records violations in each constraint's status. That is how you find out what would break before you enforce anything.

Step 1 - Installing Gatekeeper with Helm

Add the official chart repository:

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

Install Gatekeeper into its own namespace:

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

Wait for the controller and the audit deployment to become ready:

kubectl -n gatekeeper-system rollout status deploy/gatekeeper-controller-manager
kubectl -n gatekeeper-system rollout status deploy/gatekeeper-audit
kubectl -n gatekeeper-system get pods
NAME                                             READY   STATUS    RESTARTS   AGE
gatekeeper-audit-6d8c9f7b5c-x2kqm                1/1     Running   0          60s
gatekeeper-controller-manager-7f5c6b8d4f-4hj9l   1/1     Running   0          60s
gatekeeper-controller-manager-7f5c6b8d4f-9wz2t   1/1     Running   0          60s
gatekeeper-controller-manager-7f5c6b8d4f-t7m8p   1/1     Running   0          60s

Confirm that the admission webhook is registered with the API server:

kubectl get validatingwebhookconfigurations gatekeeper-validating-webhook-configuration

The Helm chart exempts the gatekeeper-system namespace from its own policies, so a bad policy cannot lock Gatekeeper out.

Step 2 - Requiring labels on namespaces

The first policy requires every namespace to carry team and environment labels, so ownership is always known. Create the template, which is the standard example from the Gatekeeper documentation:

nano k8srequiredlabels-template.yaml
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: k8srequiredlabels
spec:
  crd:
    spec:
      names:
        kind: K8sRequiredLabels
      validation:
        openAPIV3Schema:
          type: object
          properties:
            labels:
              type: array
              items:
                type: string
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8srequiredlabels

        violation[{"msg": msg, "details": {"missing_labels": missing}}] {
          provided := {label | input.review.object.metadata.labels[label]}
          required := {label | label := input.parameters.labels[_]}
          missing := required - provided
          count(missing) > 0
          msg := sprintf("missing required labels: %v", [missing])
        }

input.review.object is the object being created or updated, and input.parameters holds the values from the constraint. Each time the violation rule produces a result, the request is in violation.

Apply it and check that Gatekeeper created the new kind:

kubectl apply -f k8srequiredlabels-template.yaml
kubectl get constrainttemplate k8srequiredlabels
NAME                AGE
k8srequiredlabels   5s

Now create a constraint that applies the template to namespaces, starting in dryrun mode. System namespaces are excluded because you do not control their labels:

nano ns-require-owner.yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredLabels
metadata:
  name: ns-require-owner
spec:
  enforcementAction: dryrun
  match:
    kinds:
      - apiGroups: [""]
        kinds: ["Namespace"]
    excludedNamespaces:
      - "kube-*"
      - default
      - gatekeeper-system
  parameters:
    labels: ["team", "environment"]
kubectl apply -f ns-require-owner.yaml

Step 3 - Reviewing audit results

The audit controller runs every 60 seconds by default. After a minute, see how many existing objects violate the constraint:

kubectl get k8srequiredlabels ns-require-owner
NAME               ENFORCEMENT-ACTION   TOTAL-VIOLATIONS
ns-require-owner   dryrun               3

List the offending resources:

kubectl get k8srequiredlabels ns-require-owner -o json | jq -r '.status.violations[] | "\(.name): \(.message)"'
monitoring: missing required labels: {"environment", "team"}
staging: missing required labels: {"environment"}
web: missing required labels: {"environment", "team"}

Fix the existing namespaces before enforcing the policy, for example:

kubectl label namespace monitoring team=platform environment=production

To see all constraints and their violation counts at once, run kubectl get constraints.

Step 4 - Enforcing the policy

Once TOTAL-VIOLATIONS reaches zero, or only lists namespaces you accept, change enforcementAction: dryrun to enforcementAction: deny in ns-require-owner.yaml and apply it again:

kubectl apply -f ns-require-owner.yaml

Test it by creating a namespace without labels:

kubectl create namespace test-no-labels
Error from server (Forbidden): admission webhook "validation.gatekeeper.sh" denied the request: [ns-require-owner] missing required labels: {"environment", "team"}

A namespace with the labels is accepted:

kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Namespace
metadata:
  name: test-labeled
  labels:
    team: platform
    environment: dev
EOF
namespace/test-labeled created

Step 5 - Blocking privileged containers

A privileged container has almost full access to the node, so it should be the exception. This template checks regular containers, init containers and ephemeral debug containers of every pod:

nano k8sprivileged-template.yaml
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: k8sprivilegedcontainer
spec:
  crd:
    spec:
      names:
        kind: K8sPrivilegedContainer
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8sprivilegedcontainer

        violation[{"msg": msg}] {
          c := input_containers[_]
          c.securityContext.privileged
          msg := sprintf("privileged container is not allowed: %v", [c.name])
        }

        input_containers[c] {
          c := input.review.object.spec.containers[_]
        }

        input_containers[c] {
          c := input.review.object.spec.initContainers[_]
        }

        input_containers[c] {
          c := input.review.object.spec.ephemeralContainers[_]
        }

The constraint matches Pod objects. Deployments, StatefulSets and Jobs create their pods through controllers, and those pods pass through the same admission check, so matching pods covers every workload type:

nano no-privileged.yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sPrivilegedContainer
metadata:
  name: no-privileged-containers
spec:
  enforcementAction: dryrun
  match:
    kinds:
      - apiGroups: [""]
        kinds: ["Pod"]
    excludedNamespaces:
      - "kube-*"
      - gatekeeper-system
kubectl apply -f k8sprivileged-template.yaml
kubectl apply -f no-privileged.yaml

Check the audit results as in Step 3. CNI plugins, storage drivers and monitoring agents often run privileged in their own namespaces; add those namespaces to excludedNamespaces after reviewing them. Then switch to deny, apply, and test:

kubectl run priv-test --image=busybox:1.37 --restart=Never \
  --overrides='{"spec":{"containers":[{"name":"priv-test","image":"busybox:1.37","command":["sleep","60"],"securityContext":{"privileged":true}}]}}'
Error from server (Forbidden): admission webhook "validation.gatekeeper.sh" denied the request: [no-privileged-containers] privileged container is not allowed: priv-test

When a Deployment's pods are rejected, kubectl apply of the Deployment still succeeds, but no pods appear. The rejection is recorded in the ReplicaSet events, visible with kubectl describe replicaset -l app=your_app.

Step 6 - Allowing images only from trusted registries

The last policy only admits images whose name starts with an approved prefix, so workloads cannot pull arbitrary images from public registries:

nano k8sallowedrepos-template.yaml
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: k8sallowedrepos
spec:
  crd:
    spec:
      names:
        kind: K8sAllowedRepos
      validation:
        openAPIV3Schema:
          type: object
          properties:
            repos:
              type: array
              items:
                type: string
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8sallowedrepos

        violation[{"msg": msg}] {
          c := input_containers[_]
          not allowed(c.image)
          msg := sprintf("image %v of container %v is not from an allowed registry", [c.image, c.name])
        }

        allowed(image) {
          startswith(image, input.parameters.repos[_])
        }

        input_containers[c] {
          c := input.review.object.spec.containers[_]
        }

        input_containers[c] {
          c := input.review.object.spec.initContainers[_]
        }

Create the constraint with your registry prefixes. Always end each prefix with /: without it, registry.example.com would also allow registry.example.com.attacker.net/image.

nano allowed-repos.yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sAllowedRepos
metadata:
  name: allowed-repos
spec:
  enforcementAction: dryrun
  match:
    kinds:
      - apiGroups: [""]
        kinds: ["Pod"]
    excludedNamespaces:
      - "kube-*"
      - gatekeeper-system
  parameters:
    repos:
      - "registry.example.com/"
      - "ghcr.io/your_org/"
kubectl apply -f k8sallowedrepos-template.yaml
kubectl apply -f allowed-repos.yaml

The check compares the image string exactly as written in the manifest. An image written as nginx:1.27 does not start with docker.io/, so if you allow Docker Hub images you must list them in the form your manifests use, or require fully qualified names. Review the audit results, then switch to deny and test with an image from an unlisted registry:

kubectl run repo-test --image=nginx:1.27 --restart=Never
Error from server (Forbidden): admission webhook "validation.gatekeeper.sh" denied the request: [allowed-repos] image nginx:1.27 of container repo-test is not from an allowed registry

Step 7 - Managing exceptions

Keep exceptions in the constraint's match block so they are visible and reviewed like any other change:

  • excludedNamespaces skips named namespaces; it accepts a trailing * as a prefix wildcard.
  • namespaces limits the constraint to the listed namespaces only.
  • namespaceSelector selects namespaces by label, which lets teams opt in, for example with matchLabels: {policy-enforcement: "true"}.
  • labelSelector selects individual objects by their own labels.

When all matchers are present, an object must satisfy each of them to be checked. Prefer narrow exceptions (one namespace) over weakening the Rego logic.

Troubleshooting

Requests are not being rejected. Check that the constraint uses enforcementAction: deny, that its match actually covers the resource and namespace, and that the template compiled. kubectl get constrainttemplate k8sallowedrepos -o yaml shows status.byPod[].errors if the Rego has a syntax error.

A constraint cannot be created: no matches for kind. The template has not finished creating its CRD, or it failed to compile. Wait a few seconds after applying the template, check its status, and apply the constraint again.

Everything is blocked after a bad policy. Switch the constraint back to dryrun, or delete it with kubectl delete k8sallowedrepos allowed-repos. Gatekeeper's own namespace is exempt, so it keeps running.

Audit shows no violations even though some exist. The audit runs every 60 seconds and reports up to 20 violations per constraint by default; TOTAL-VIOLATIONS still shows the full count. Check the audit logs with kubectl -n gatekeeper-system logs deploy/gatekeeper-audit --tail 50.

Testing Rego before applying it. The Gatekeeper project provides the gator CLI, which runs templates and constraints against sample objects locally, so you can test policies in CI before they reach the cluster.

Conclusion

Gatekeeper is now running in your cluster with three policies: required ownership labels on namespaces, no privileged containers, and images only from approved registries, each introduced in dryrun, cleaned up through audit results, and then enforced. The same workflow applies to any new policy. As next steps, explore the maintained policies in the Gatekeeper policy library before writing your own, add gator tests to the repository that stores your constraints, and manage templates and constraints through GitOps so every change is reviewed.