OPA Gatekeeper: writing constraint templates from scratch

Enforce org-wide Kubernetes policy with Rego — required labels, blocked images, and audit of existing violations.

Dec 3, 2025·Updated ·5 min readAdvanced·By SecOpsLog · documentation-verified

The API server accepts a Deployment with no owner, an image from any registry on the internet and a container running as root, because none of that is invalid. It is only unwanted, and unwanted is a policy question. Gatekeeper answers it at admission with two objects: a ConstraintTemplate holds the logic and declares the parameters it accepts, and a Constraint instantiates that template with parameter values and a scope. The split is what lets a platform team publish "every Deployment needs an owner label" once and let each team decide which namespaces it applies to.

One request, three enforcement actions, and the audit loop

The template carries the code; the constraint carries the scope, the parameters and the enforcementAction. Admission evaluates the constraint against the incoming object; audit evaluates it against what already exists, which is what makes dryrun a migration tool rather than a no-op.

Gatekeeper enforcement path: a request reaches the validating webhook, every Constraint whose match covers the object runs its template code, and a violation is handled by the constraint enforcementAction: dryrun admits and records it through audit, warn admits and returns a warning to the client, deny rejects with 403. Audit evaluates the same constraints against existing objects on an interval and writes violations into the constraint status; a deny with no dryrun period fails running workloads at their next restart kubectl applyDeployment, ns shopvalidating webhookevery Constraint whosematch covers this objectTemplate codeRego v1 or CELviolation -> msga violation, by the constraint’s enforcementActiondryrunadmitted, nothing shown;violation kept by auditin constraint statuswarnadmitted; the client seesWarning: [deployments-need-owner] missing labeldeny (the default)403 Forbidden: admissionwebhook "validation.gatekeeper.sh" denied itAudit: the same rules against what already runseveryinterval: each constraint vs every objectwritesstatus.violations, capped per constraintgivesthe backlog a dryrun period collectsthe deny nobody wanteduntouched until its nextrestart; deny with nodryrun period fails it insomeone else’s incidentRollout order is the order of the words: dryrun for a cycle, export violations, warn, then deny.

Rego or CEL: pick one per template

Since Gatekeeper 3.19 the template's code array can hold Rego (v0 by default, v1 when the source says so) or CEL through the K8sNativeValidation engine, and when both are present only CEL is evaluated, with no fallback. The old spec.targets[].rego field still works and takes precedence over code, which is a migration trap: a template edited to add CEL while the legacy field is still there keeps running the Rego.

Which engine

ChooseWhenCost
CEL (K8sNativeValidation)the rule reads only the object itself: labels, image prefixes, a field must be setno external data, no referential checks across objects
Rego v1the rule needs data from other objects (data.inventory), external data providers, or logic that CEL expressions make unreadablea second language for the team; test it locally with opa test before it reaches admission
Neither: the librarythe rule is one of the common ones (required labels, allowed repos, no privileged, required probes)read the template before applying it; the parameters differ from what you would have written
template-required-labels.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
code:
- engine: Rego
source:
version: "v1"
rego: |
package k8srequiredlabels
violation contains {"msg": msg} if {
some required in input.parameters.labels
not input.review.object.metadata.labels[required]
msg := sprintf("missing required label: %v", [required])
}

The violation rule's msg is what a developer sees on a rejected kubectl apply, so it names the label rather than saying "policy violated". The schema under validation is the contract for constraints: a Constraint that passes a parameter the schema does not declare is rejected on creation, which is the cheapest place to catch a typo.

The constraint carries scope and the enforcement action

constraint-owner-label.yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredLabels
metadata:
name: deployments-need-owner
spec:
enforcementAction: dryrun # dryrun -> warn -> deny, one step per release cycle
match:
kinds:
- apiGroups: ["apps"]
kinds: ["Deployment"]
namespaces: ["shop", "payments"]
excludedNamespaces: ["kube-system", "gatekeeper-system"]
parameters:
labels: ["owner", "cost-center"]

match decides the blast radius: which kinds, which namespaces, optionally label selectors and excluded namespaces. A required-label rule on every Pod cluster-wide catches the CNI DaemonSet on its next restart; the same rule on Deployments in two application namespaces catches only what the two teams that asked for it deploy. enforcementAction decides what a violation does. dryrun admits the object and records the violation in the constraint's status through the audit loop; warn admits it and returns a warning to the client; deny, the default when the field is omitted, rejects it. The rollout order is the order of those three words.

bash — dryrun results, then the first deny
kubectl get k8srequiredlabels deployments-need-owner -o jsonpath="{.status.totalViolations}{'\n'}{range .status.violations[*]}{.namespace}/{.name}: {.message}{'\n'}{end}"
3
shop/legacy-importer: missing required label: owner
shop/legacy-importer: missing required label: cost-center
payments/reconciler: missing required label: cost-center
two Deployments to fix, found by audit without blocking anyone
kubectl apply -f deploy-no-owner.yaml # after enforcementAction: deny
Error from server (Forbidden): admission webhook "validation.gatekeeper.sh" denied the request: [deployments-need-owner] missing required label: owner

Audit is the part of Gatekeeper that makes dryrun useful: on an interval it evaluates every constraint against the objects that already exist and writes the violations into the constraint's status, capped by --constraint-violations-limit per constraint. That list is the migration backlog. --log-denies on the controller logs every deny, dryrun and warn decision, which is the record to keep when a team asks why their deploy failed last Tuesday.

The second constraint is usually from the library

Allowed registries, no privileged containers, required probes, no latest tags, disallowed capabilities: the Gatekeeper policy library ships templates for these with tested parameters, and applying one of them takes a Constraint and no Rego. The template's kind name is the interface; the Constraint below restricts images to two registries for every Pod in application namespaces, and it is the rule that turns image signing at admission into something a policy engine can also express.

constraint-allowed-repos.yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sAllowedRepos # template from the Gatekeeper library
metadata:
name: only-acme-registries
spec:
enforcementAction: warn # warn for a cycle, then deny
match:
kinds:
- apiGroups: [""]
kinds: ["Pod"]
excludedNamespaces: ["kube-system", "gatekeeper-system", "monitoring"]
parameters:
repos:
- "registry.acme.dev/"
- "ghcr.io/acme/"
Straight to deny breaks what already runs, at its next restart
A constraint with deny and no dryrun period does not affect running Pods until they are recreated, which is a rollout, a node drain or a crash. The failure therefore arrives later, in someone else’s incident. Dryrun for a release cycle, export the violations, give owners a date, then warn, then deny; the same discipline Pod Security Admission uses with audit and warn.
What Gatekeeper is for, next to Pod Security Admission
Pod Security Admission
Fixed profiles, built into the API server
Pod-level security fields only
No parameters, no custom messages
The floor for every namespace
Gatekeeper constraints
Your rules: labels, registries, probes, quotas
Any kind, with referential data if needed
dryrun/warn/deny per constraint, audit of existing objects
The organisation’s rules on top of the floor

The same Rego runs before admission as well: Conftest evaluates it against manifests in CI, so a missing label fails the pipeline instead of the deploy, and the OPA policy on Terraform plans is the same engine pointed at infrastructure. Gatekeeper is the last line, where the object meets the cluster; Pod Security Admission is the floor it stands on.

Related posts

Quick reference