Gatekeeper ConstraintTemplates at scale
Templates, constraints, and syncing data.
A cookie cutter and a tray of cookies. The cutter holds the shape; the tray holds however many cookies you press out of it, each one finished with different sprinkles. Gatekeeper splits Kubernetes policy the same way. A ConstraintTemplate is the cutter: the rule's logic, written once in Rego (the small policy language used by OPA, the Open Policy Agent engine that Gatekeeper embeds), plus a schema listing the knobs the rule accepts. A Constraint is one pressing of that cutter: which resources it applies to, and what those knobs are set to. Fifteen teams can share one template and still get fifteen different answers out of it.
The failure this design prevents is copy-paste policy. Hand-write fresh Rego for every new requirement and you finish the quarter with eighteen near-identical rules, four of which forgot to check initContainers (helper containers that run to completion before the main container starts). An attacker never has to beat your policy engine when one of your eighteen copies has a hole. They put the unapproved image in an init container and walk in. One tested template, parameterized eighteen ways, has one hole to find and one place to fix it.
What Applying a Template Actually Does
Applying a ConstraintTemplate is a code deploy wearing a config file's clothes. Gatekeeper reads it, compiles the Rego, and creates a brand-new CRD (CustomResourceDefinition, the mechanism that teaches the Kubernetes API server about an object type it did not ship with). From that moment the API server accepts objects of kind K8sAllowedRepos, checks their parameters against the schema you wrote, and Gatekeeper watches for them. The template is the program. The constraint is configuration that the API server type-checks on your behalf before Gatekeeper ever sees it.
apiVersion: templates.gatekeeper.sh/v1kind: ConstraintTemplatemetadata:name: k8sallowedrepos # MUST equal lowercase(spec.crd.spec.names.kind)spec:crd:spec:names:kind: K8sAllowedReposvalidation:openAPIV3Schema:type: objectproperties:repos:type: arrayitems:type: stringtargets:- target: admission.k8s.gatekeeper.shrego: |package k8sallowedreposimport data.lib.containersviolation[{"msg": msg}] {cs := containers.list(input.review.object)c := cs[_]matches := [r | r := input.parameters.repos[_]; startswith(c.image, r)]count(matches) == 0msg := sprintf("container <%v> has an invalid image repo <%v>, allowed repos are %v",[c.name, c.image, input.parameters.repos])}libs:- |package lib.containers# every place a container image can hide in a Pod speclist(obj) := cs {cs := array.concat(array.concat(object.get(obj, ["spec", "containers"], []),object.get(obj, ["spec", "initContainers"], [])),object.get(obj, ["spec", "ephemeralContainers"], []))}
Three details in there carry most of the scaling value. input.review.object is the resource the API server is asking about. input.parameters is whatever that particular constraint set. And libs holds shared Rego that the template's main rule imports, where library packages have to live under package lib.<name> and get imported as data.lib.<name>; the constraint framework enforces that naming, it is not a style preference. The list helper is why the init-container hole from a moment ago cannot come back, because every template that reasons about images pulls containers from the same three fields.
Two footnotes on that Rego. The violation[{"msg": msg}] head is the v0 dialect that the upstream Gatekeeper policy library still ships; if your build compiles template Rego as v1, the same rule is written violation contains {"msg": msg} if { ... }. And listing spec.ephemeralContainers covers the Pod at creation time only. A debug container attached to a running Pod later arrives through the pods/ephemeralcontainers subresource, and a webhook rule that says resources: ["*"] does not match subresources. Read your own ValidatingWebhookConfiguration before you count that path as covered.
kubectl apply -f template.yamlsleep 3 # give the controller a moment to mint the CRDkubectl get crd k8sallowedrepos.constraints.gatekeeper.shkubectl get constrainttemplate k8sallowedrepos -o jsonpath='{.status.created}{"\n"}'
constrainttemplate.templates.gatekeeper.sh/k8sallowedrepos createdNAME CREATED ATk8sallowedrepos.constraints.gatekeeper.sh 2026-07-27T09:14:22Ztrue
Two failures greet almost everybody on day one. Name the template anything other than the lowercase form of its kind and Gatekeeper's own webhook rejects it on the spot, because Gatekeeper validates its own resources through the same admission path it uses for yours.
kubectl apply -f bad-name.yaml
Error from server (Forbidden): error when creating "bad-name.yaml": admission webhook"validation.gatekeeper.sh" denied the request: ConstraintTemplate's name"allowedrepos" is not equal to the lowercase of CRD's Kind: "k8sallowedrepos"
The second failure is Rego that does not compile. Normally you get rejected for that too, since the webhook compiles the template before the API server stores it. Normally is the load-bearing word. Gatekeeper installs its validating webhook with failurePolicy: Ignore so that a sick policy controller cannot take the whole cluster offline, which means that if the Gatekeeper pods are restarting or wedged at the moment you apply, the API server shrugs, stores the broken template, and the compile error surfaces only in the object's status.
kubectl apply -f broken-rego.yamlkubectl get constrainttemplate k8sbrokenrule \-o jsonpath='{.status.byPod[0].errors}' | jq .
constrainttemplate.templates.gatekeeper.sh/k8sbrokenrule created[{"code": "ingest_error","location": "","message": "Could not ingest Rego: rego_parse_error: unexpected } token"}]
Wire that second command into whatever watches your cluster. A template carrying a non-empty status.byPod[].errors is a control you believe you have and do not, which is worse than having no control at all, because nobody goes looking for the gap. The same logic argues for flipping the webhook's failure policy to Fail once you trust your Gatekeeper deployment's availability, so a dead policy engine blocks writes instead of waving them through.
One Template, Many Constraints
Now press the cutter twice. Production gets a short allowlist and a hard no. The sandbox clusters get a longer list and record-only mode, so you find out what would break before anything does.
apiVersion: constraints.gatekeeper.sh/v1beta1kind: K8sAllowedReposmetadata:name: allowed-repos-prodspec:enforcementAction: deny # the default if you omit itmatch:kinds:- apiGroups: [""]kinds: ["Pod"]namespaceSelector:matchLabels:acme.example/tier: prodexcludedNamespaces: ["kube-system", "gatekeeper-system"]parameters:repos:- "registry.internal.acme.example/"- "ghcr.io/acme/"---apiVersion: constraints.gatekeeper.sh/v1beta1kind: K8sAllowedReposmetadata:name: allowed-repos-sandboxspec:enforcementAction: dryrunmatch:kinds:- apiGroups: [""]kinds: ["Pod"]namespaceSelector:matchLabels:acme.example/tier: sandboxparameters:repos:- "registry.internal.acme.example/"- "docker.io/library/"
The match block is your blast-radius dial, and it takes more than kinds. namespaces and excludedNamespaces accept names with a trailing wildcard such as team-*. labelSelector filters on labels of the object itself. namespaceSelector filters on labels of the namespace the object lands in. scope narrows to Cluster or Namespaced resources. Prefer namespaceSelector for anything that grants leniency, because a label on the Pod can be set by anyone who can create a Pod, and a self-labelled exemption is an opt-out button for attackers. A label on the namespace usually needs cluster-admin. Gatekeeper also honours the admission.gatekeeper.sh/ignore namespace label, but only for namespaces you listed at install time with --exempt-namespace, which is the mechanism for bootstrap namespaces that must come up before Gatekeeper does.
kubectl apply -f constraints.yamlkubectl get constraints
k8sallowedrepos.constraints.gatekeeper.sh/allowed-repos-prod createdk8sallowedrepos.constraints.gatekeeper.sh/allowed-repos-sandbox createdNAME ENFORCEMENT-ACTION TOTAL-VIOLATIONSk8sallowedrepos.constraints.gatekeeper.sh/allowed-repos-prod deny <none>k8sallowedrepos.constraints.gatekeeper.sh/allowed-repos-sandbox dryrun <none>
Both counts read <none>, which trips people up. Enforcement started the instant the constraint existed, but the numbers come from a separate background job, the audit loop, which re-checks everything already running every 60 seconds by default (--audit-interval). Wait one sweep and look again.
sleep 90kubectl get constraints
NAME ENFORCEMENT-ACTION TOTAL-VIOLATIONSk8sallowedrepos.constraints.gatekeeper.sh/allowed-repos-prod deny 3k8sallowedrepos.constraints.gatekeeper.sh/allowed-repos-sandbox dryrun 41
Forty-one sandbox violations on day one is the number that tells you a same-day flip to deny in production would have paged you at dinner. That is the whole argument for dry-run, expressed as an integer you can paste into a rollout ticket. The three in production are workloads that predate the constraint, still running, still violating, and now visible.
One more thing about that schema. The generated CRD is structural, so the API server prunes any parameter you did not declare. Typo repos as repo and the apply succeeds with no complaint at all.
kubectl apply -f constraint-typo.yamlkubectl get k8sallowedrepos allowed-repos-prod -o jsonpath='{.spec.parameters}{"\n"}'sleep 90kubectl get k8sallowedrepos allowed-repos-prod -o jsonpath='{.status.totalViolations}{"\n"}'
k8sallowedrepos.constraints.gatekeeper.sh/allowed-repos-prod configured{}0
Follow what an empty parameters does to the rule, because the answer is the opposite of what most people guess. input.parameters.repos is undefined, so the comprehension produces an empty list, so count(matches) == 0 holds for every container. You would expect production to start rejecting everything. Instead the next line builds the message with sprintf(..., [c.name, c.image, input.parameters.repos]), that reference is undefined too, so msg never gets a value, so the rule body fails and no violation is produced at all. A one-character typo turned a deny into an allow, quietly, with a green apply and a status that reads zero.
not X succeeds only when X is undefined or false, and the number 0 is neither of those. So not count(matches) never succeeds, and a violation rule written that way is dead code that reports zero forever. It applies cleanly, shows 0 under TOTAL-VIOLATIONS, and stops nothing. Both bugs land in the same place: a control that looks healthy and blocks nothing. Zero violations is not evidence. The only evidence is a manifest that must be denied, run against the policy on every change, plus one that must pass.What a Denied Admission Actually Returns
Underneath, Gatekeeper's webhook always answers the API server with HTTP 200, meaning the request reached the webhook and it had an opinion, plus an AdmissionReview body carrying allowed: true or allowed: false and a message. The API server is what turns a false into the rejection your client sees. Three enforcementAction values decide which of those the caller gets, and two of the three are completely invisible to the person who wrote the bad manifest.
kubectl apply -n payments -f - <<'EOF'apiVersion: v1kind: Podmetadata:name: webspec:containers:- name: webimage: docker.io/library/nginx:1.29EOFecho "exit=$?"
Error from server (Forbidden): error when creating "STDIN": admission webhook"validation.gatekeeper.sh" denied the request: [allowed-repos-prod] container<web> has an invalid image repo <docker.io/library/nginx:1.29>, allowed reposare ["registry.internal.acme.example/", "ghcr.io/acme/"]exit=1
Read that reply closely, because it is the contract the rest of your tooling depends on. HTTP 403 Forbidden, the code that means authenticated and refused, so the object was never persisted. The webhook's name, so you know which of your admission controllers spoke. The constraint name in square brackets, which is why deny-privileged-prod beats policy-7 as a name. Then your msg string verbatim, which is the only place a developer learns what to change. A message that reads "violation found" costs your platform team a support ticket every single time it fires.
Switch the same constraint to warn and the object is created; the message arrives on standard error as a warning line and the exit code is 0. Switch it to dryrun and the caller sees nothing whatsoever. The violation exists only in the constraint's status and in the gatekeeper_violations metric.
kubectl patch k8sallowedrepos allowed-repos-prod --type=merge \-p '{"spec":{"enforcementAction":"warn"}}'kubectl apply -n payments -f bad-pod.yamlecho "exit=$?"
k8sallowedrepos.constraints.gatekeeper.sh/allowed-repos-prod patchedWarning: [allowed-repos-prod] container <web> has an invalid image repo <docker.io/library/nginx:1.29>, allowed repos are ["registry.internal.acme.example/", "ghcr.io/acme/"]pod/web createdexit=0
Note the created Pod and the warning that changed nothing. warn is a fine two-week teaching mode and a terrible permanent state, because people learn to scroll past yellow text, and after a month your control is decoration with good intentions. Put the flip-to-deny date in the constraint's annotations and hold it. Newer Gatekeeper releases add a fourth value, enforcementAction: scoped, paired with a scopedEnforcementActions list. It exists because Gatekeeper now evaluates constraints at several enforcement points (the admission webhook, the background audit, the gator command-line tool, and any Kubernetes ValidatingAdmissionPolicy objects it generates), and scoped lets one constraint deny at one of those while only warning at another. Check what your installed version serves before you write it into a manifest.
You Matched Pods, They Shipped a Deployment
This one costs teams an afternoon roughly once per cluster. Your constraint matches Pod. Almost nobody applies Pods; they apply Deployments, and the Deployment sails through admission untouched, because a Deployment is not a Pod.
kubectl apply -n payments -f deployment.yamlkubectl get deploy -n payments webkubectl describe rs -n payments web-6f8c9d5b7 | tail -4
deployment.apps/web createdNAME READY UP-TO-DATE AVAILABLE AGEweb 0/3 0 0 25sEvents:Type Reason Age From Message---- ------ ---- ------------------- -------Warning FailedCreate 14s replicaset-controller Error creating: admission webhook "validation.gatekeeper.sh" denied the request: [allowed-repos-prod] container <web> has an invalid image repo <docker.io/library/nginx:1.29>, allowed repos are ["registry.internal.acme.example/", "ghcr.io/acme/"]
The developer sees a green kubectl apply and a Deployment stuck at 0 of 3, and the actual reason sits in a ReplicaSet's events, two objects away. Their continuous integration job (the automation that runs on every merge) reports success. The pipeline moves on. You have three ways out: match the workload kinds directly (Deployment, StatefulSet, DaemonSet, Job, CronJob) and reach into spec.template.spec in the Rego, keep matching Pods only and accept the late feedback, or have Gatekeeper generate the implied Pod for you and check that.
apiVersion: expansion.gatekeeper.sh/v1alpha1kind: ExpansionTemplatemetadata:name: expand-deploymentsspec:applyTo:- groups: ["apps"]kinds: ["Deployment", "StatefulSet", "DaemonSet"]versions: ["v1"]templateSource: "spec.template"generatedGVK:group: ""version: "v1"kind: "Pod"
With that applied, creating the Deployment runs your Pod-scoped constraints against the Pod it *would* produce, and the denial arrives at kubectl apply time, with the message flagged as implied by expand-deployments so you can tell it came from a generated object rather than a real one. Resource expansion is a newer feature gated by a controller flag (--enable-generator-resource-expansion) and served under an alpha or beta API version depending on your release, so run kubectl api-resources | grep expansion and confirm rather than assuming. Whichever route you take, the test that proves it works is the boring one: apply a violating Deployment, not a violating Pod, because Deployments are what your users actually write.
Teaching a Policy About the Rest of the Cluster
A doorman who can only see the person standing in front of him can check that person's ID. He cannot notice that the same name has already walked in three times tonight. Admission works the same way. input.review describes one object, so any rule that needs to compare against what already exists in the cluster is blind without help. That help is the Config resource, which tells Gatekeeper to replicate chosen resource types into OPA's in-memory cache.
apiVersion: config.gatekeeper.sh/v1alpha1kind: Configmetadata:name: config # the name and namespace are both fixednamespace: gatekeeper-systemspec:sync:syncOnly:- group: "networking.k8s.io"version: "v1"kind: "Ingress"- group: ""version: "v1"kind: "Namespace"
Synced objects land at predictable paths. Cluster-scoped things sit at data.inventory.cluster[<groupVersion>][<kind>][<name>], namespaced things at data.inventory.namespace[<ns>][<groupVersion>][<kind>][<name>], where <groupVersion> is "v1" for core types and "networking.k8s.io/v1" for the rest. Recent versions also let you spread the same list across separate SyncSet objects instead of the single cluster-wide Config, which helps when different teams own different slices of what gets cached. Either way, a rule can now ask a question that no single object can answer: is this hostname already claimed by somebody else?
package k8suniqueingresshostviolation[{"msg": msg}] {input.review.kind.kind == "Ingress"input.review.kind.group == "networking.k8s.io"host := input.review.object.spec.rules[_].hostother := data.inventory.namespace[ns]["networking.k8s.io/v1"]["Ingress"][name]other.spec.rules[_].host == hostnot same_object(other, input.review)msg := sprintf("host %v is already claimed by ingress %v/%v", [host, ns, name])}same_object(obj, review) {obj.metadata.namespace == review.object.metadata.namespaceobj.metadata.name == review.object.metadata.name}
The attack this stops is worth spelling out. Without it, anyone who can create an Ingress in any namespace can claim payments.acme.example, and depending on how your ingress controller breaks the tie, some or all of that traffic starts arriving at their Pods instead of the payments team's. Session cookies, bearer tokens, card numbers, whatever the client was about to send. It is a takeover that needs no exploit and leaves nothing to patch. The control is one policy that can see the other Ingresses.
kubectl apply -n scratch -f copycat-ingress.yaml
Error from server (Forbidden): error when creating "copycat-ingress.yaml": admissionwebhook "validation.gatekeeper.sh" denied the request: [unique-ingress-host] hostpayments.acme.example is already claimed by ingress payments/checkout
Secret to syncOnly. Sync ConfigMap only when you know what is inside them, budget memory for the object count you actually have, and watch the gatekeeper_sync metric so that a fat new resource type does not get the controller killed for using too much memory while it is holding your guardrails.Keep the Cluster Out of Your Edit Loop
Rego inside a CRD is still code, and a cluster is a miserable compiler: slow, shared, and it reports parse errors into a status field. Gatekeeper ships gator, a command-line tool that evaluates templates and constraints against local files with no cluster involved, which makes a policy change reviewable like any other pull request.
gator test -f template.yaml -f constraints.yaml -f bad-pod.yamlecho "exit=$?"
[allowed-repos-prod] Message: "container <web> has an invalid image repo <docker.io/library/nginx:1.29>, allowed repos are [\"registry.internal.acme.example/\", \"ghcr.io/acme/\"]"exit=1
Run that on every commit that touches policy, with fixtures for both answers: one manifest that must be denied and one that must pass. The passing fixture is the one people skip, and it is the one that catches the template that started denying everything after a bad refactor. gator verify goes further, using a Suite resource that holds named cases with the message each one expects, which is where a shared platform repository should end up.
Rolling Out Without Breaking Friday
Ship every new constraint as dryrun first, let the audit loop run for a week, read the violation count per namespace, then flip to deny in waves by namespace label. Publish the date of each wave and meet it. A dry-run that renews itself quietly every quarter has taught your organization that policy is negotiable, and that lesson is far harder to reverse than any YAML.
Two numbers will surprise you at scale. Constraint status keeps a capped sample of violations, 20 by default, set by --constraint-violations-limit, so a constraint reporting 20 findings may really have 2,000. Read totals from the gatekeeper_violations metric and treat status as something to eyeball. The second number is latency. Every webhook call sits on the API server's hot path, so watch apiserver_admission_webhook_admission_duration_seconds as your template count grows. Rego that walks a large synced inventory on every Pod creation is how a policy engine becomes an availability incident, and slow admission looks exactly like a broken cluster to everyone downstream of it.
Treat template changes as schema migrations, because that is what they are. Adding a required parameter breaks every existing constraint that lacks it, and constraints are usually owned by teams who did not read your release notes. Keep templates and constraints in Git as the source of truth, rehearse Gatekeeper upgrades on a lab cluster one version ahead, and remember that an empty gatekeeper-system after a cluster rebuild is an open door, not a clean slate.
Try this
Run this on a kind cluster or any lab you can throw away, so you see the real shape of the output rather than a screenshot from somebody's blog. Install Gatekeeper, apply the template, apply the prod constraint on its own with enforcementAction: warn, then create one Deployment whose image comes from Docker Hub. Wait out an audit sweep and inspect all three layers.
kubectl get constrainttemplate -o custom-columns=NAME:.metadata.name,CREATED:.status.createdkubectl get constraintskubectl get k8sallowedrepos allowed-repos-prod -o jsonpath='{.status.totalViolations}{"\n"}'
NAME CREATEDk8sallowedrepos trueNAME ENFORCEMENT-ACTION TOTAL-VIOLATIONSk8sallowedrepos.constraints.gatekeeper.sh/allowed-repos-prod warn 11
Then break it on purpose twice: rename repos to repo in the constraint and watch the count fall to 0 while nothing is being blocked, and change count(matches) == 0 to not count(matches) and watch the same 0 appear for a different reason. Feeling both of those fail open is worth more than reading about them.
Takeaway
Make the template a tested library, make the constraint nothing but parameters and a match block, put a published date on every dry-run before it becomes a deny, and never accept a zero from a policy you have not watched deny something.
Next: Kyverno's mutate, generate and verifyImages rules, where policy repairs and proves rather than only refusing.
spec.crd.spec.names.kind is K8sAllowedRepos. What does Gatekeeper do with it?validation.gatekeeper.sh. Templates add CRDs, never webhook registrations.enforcementAction: dryrun. A developer applies a Pod that violates it. What does that developer see?warn. The two get confused constantly, and the difference is precisely whether the caller is told anything at all.kubectl apply -f deployment.yaml prints deployment.apps/web created, but kubectl get deploy web shows 0/3 and kubectl describe rs web-6f8c9d5b7 shows a FailedCreate event quoting validation.gatekeeper.sh and your allowed-repos constraint. What is happening, and what fixes it?failurePolicy (Gatekeeper ships Ignore, so the request would have been allowed). These events quote your own constraint's message, so the webhook answered fine.