OPA Gatekeeper: writing constraint templates from scratch
Enforce org-wide Kubernetes policy with Rego — required labels, blocked images, and audit of existing violations.
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.
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.
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
| Choose | When | Cost |
|---|---|---|
CEL (K8sNativeValidation) | the rule reads only the object itself: labels, image prefixes, a field must be set | no external data, no referential checks across objects |
| Rego v1 | the rule needs data from other objects (data.inventory), external data providers, or logic that CEL expressions make unreadable | a second language for the team; test it locally with opa test before it reaches admission |
| Neither: the library | the 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 |
apiVersion: templates.gatekeeper.sh/v1kind: ConstraintTemplatemetadata:name: k8srequiredlabelsspec:crd:spec:names:kind: K8sRequiredLabelsvalidation:openAPIV3Schema:type: objectproperties:labels:type: arrayitems:type: stringtargets:- target: admission.k8s.gatekeeper.shcode:- engine: Regosource:version: "v1"rego: |package k8srequiredlabelsviolation contains {"msg": msg} if {some required in input.parameters.labelsnot 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
apiVersion: constraints.gatekeeper.sh/v1beta1kind: K8sRequiredLabelsmetadata:name: deployments-need-ownerspec:enforcementAction: dryrun # dryrun -> warn -> deny, one step per release cyclematch: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.
kubectl get k8srequiredlabels deployments-need-owner -o jsonpath="{.status.totalViolations}{'\n'}{range .status.violations[*]}{.namespace}/{.name}: {.message}{'\n'}{end}"3shop/legacy-importer: missing required label: ownershop/legacy-importer: missing required label: cost-centerpayments/reconciler: missing required label: cost-centertwo Deployments to fix, found by audit without blocking anyonekubectl apply -f deploy-no-owner.yaml # after enforcementAction: denyError from server (Forbidden): admission webhook "validation.gatekeeper.sh" denied the request: [deployments-need-owner] missing required label: ownerAudit 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.
apiVersion: constraints.gatekeeper.sh/v1beta1kind: K8sAllowedRepos # template from the Gatekeeper librarymetadata:name: only-acme-registriesspec:enforcementAction: warn # warn for a cycle, then denymatch:kinds:- apiGroups: [""]kinds: ["Pod"]excludedNamespaces: ["kube-system", "gatekeeper-system", "monitoring"]parameters:repos:- "registry.acme.dev/"- "ghcr.io/acme/"
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.