Blocking Unlabelled Pods with Kyverno: From ClusterPolicy to CEL on a Local kind Cluster
Note: This is an independent walkthrough by a learner and is not officially endorsed by the CNCF or Kyverno maintainers. Summary In this walkthrough, I enforced mandatory team labeling on Kubernetes Pods using both Kyv
Note: This is an independent walkthrough by a learner and is not officially endorsed by the CNCF or Kyverno maintainers.
Summary
In this walkthrough, I enforced mandatory team labeling on Kubernetes Pods using both Kyverno's legacy ClusterPolicy and the modern CEL-based ValidatingPolicy. I learned how Kyverno extends the Kubernetes API with CRDs and how CEL-based policies run in Kyverno's admission webhook using a standard expression language instead of Kyverno's custom pattern-matching engine. This was built locally on a kind cluster to compare the two architectures side-by-side.Environment Setup
Everything was tested locally using a standard kind cluster. I Note: This is an independent walkthrough by a learner and is not officially endorsed by the CNCF or Kyverno maintainers.
1. Summary
In this walkthrough, I enforced mandatory team labeling on Kubernetes Pods using both Kyverno's legacy ClusterPolicy and the modern CEL-based ValidatingPolicy. I learned how Kyverno extends the Kubernetes API with CRDs and how CEL-based policies run in Kyverno's admission webhook using a standard expression language instead of Kyverno's custom pattern-matching engine. This was built locally on a kind cluster to compare the two architectures side-by-side.
2. Environment Setup
Everything was tested locally using a standard kind cluster. I installed Kyverno via Helm, using chart version kyverno-3.9.2 (App version v1.19.2) that supports CEL policies.
Out of the box, Kubernetes has no idea what a Kyverno policy is. To verify the installation, I checked if Kyverno successfully registered its Custom Resource Definitions (CRDs), which extend the Kubernetes API so it can understand our custom policy objects:
kubectl get crd | findstr "policies.kyverno.io"
Output:
cleanuppolicies.kyverno.io Namespaced v2(storage),v2beta1 2026-10-24T15:40:36Z
clustercleanuppolicies.kyverno.io Cluster v2(storage),v2beta1 2026-10-24T15:40:36Z
clusterpolicies.kyverno.io Cluster v1(storage),v2beta1 2026-10-24T15:40:37Z
deletingpolicies.policies.kyverno.io Cluster v1,v1alpha1,v1beta1(storage) 2026-10-24T15:40:36Z
generatingpolicies.policies.kyverno.io Cluster v1,v1alpha1,v1beta1(storage) 2026-10-24T15:40:36Z
imagevalidatingpolicies.policies.kyverno.io Cluster v1,v1alpha1,v1beta1(storage) 2026-10-24T15:40:36Z
mutatingpolicies.policies.kyverno.io Cluster v1,v1alpha1,v1beta1(storage) 2026-10-24T15:40:36Z
namespaceddeletingpolicies.policies.kyverno.io Namespaced v1,v1beta1(storage) 2026-10-24T15:40:36Z
namespacedgeneratingpolicies.policies.kyverno.io Namespaced v1,v1beta1(storage) 2026-10-24T15:40:36Z
namespacedimagevalidatingpolicies.policies.kyverno.io Namespaced v1,v1beta1(storage) 2026-10-24T15:40:36Z
namespacedmutatingpolicies.policies.kyverno.io Namespaced v1,v1beta1(storage) 2026-10-24T15:40:36Z
namespacedvalidatingpolicies.policies.kyverno.io Namespaced v1,v1beta1(storage) 2026-10-24T15:40:36Z
policies.kyverno.io Namespaced v1(storage),v2beta1 2026-10-24T15:40:37Z
policyexceptions.policies.kyverno.io Namespaced v1,v1alpha1,v1beta1(storage) 2026-10-24T15:40:36Z
validatingpolicies.policies.kyverno.io Cluster v1,v1alpha1,v1beta1(storage) 2026-10-24T15:40:36Z
Seeing validatingpolicies.policies.kyverno.io in the output confirmed the CRDs were installed.
3. Policy v1: The Legacy ClusterPolicy
Before trying CEL, I wanted to see how this is traditionally done. In older versions of Kyverno, you create a ClusterPolicy that relies on Kyverno's custom pattern-matching engine.
Based on the official Kyverno Policy Library, a basic policy to enforce a label looks like this:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-team-label
spec:
validationFailureAction: Enforce
rules:
- name: check-team-label
match:
any:
- resources:
kinds:
- Pod
validate:
message: "The label `team` is required."
pattern:
metadata:
labels:
team: "?*"
Notice the syntax: it tells the Kyverno controller to watch for Pod resources, and it uses a custom wildcard "?*" to say "this label must exist and have a value."
When I applied this policy and tried to create an unlabelled pod, the Kyverno controller caught it and blocked the request, sending back this rejection message:
Error from server: admission webhook "validate.kyverno.svc-fail" denied the request:
resource Pod/default/c1 was blocked due to the following policies
require-team-label:
check-team-label: 'validation error: The label `team` is required. rule check-team-label failed at path /metadata/labels/team/'
4. Policy v2: The CEL ValidatingPolicy
Next, I deleted the legacy ClusterPolicy and replaced it with a modern ValidatingPolicy written in Common Expression Language (CEL).
Here is the exact ValidatingPolicy manifest I applied to the cluster:
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: require-team-label
spec:
validationActions:
- Deny
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
validations:
- expression: "has(object.metadata.labels) && 'team' in object.metadata.labels"
message: "The label `team` is required."
Breaking Down the CEL Logic
The core of this policy is the CEL expression:
has(object.metadata.labels) && 'team' in object.metadata.labels
-
has(object.metadata.labels)safely checks if the incoming Pod object has any labels attached at all (preventing a null reference error). -
'team' in object.metadata.labelsevaluates whether the specific keyteamexists within that metadata map.
Testing the CEL Policy
Negative Test (Missing Label):
First, I attempted to launch an unlabelled Pod:
kubectl run c1 --image=nginx
As expected, the request was immediately denied by the admission webhook:
Error from server: admission webhook "vpol.validate.kyverno.svc-fail" denied the request: Policy require-team-label failed: The label `team` is required.
Positive Test (Valid Label Attached):
Next, I ran the test command providing the required label:
kubectl run c2 --image=nginx --labels=team=payments
Output:
pod/c2 created
5. Comparison: What Changed?
Having deployed and tested both, the shift from traditional Kyverno policies to CEL-based validation introduces some major architectural changes. Here is a quick breakdown of the core differences:
| Feature | Legacy ClusterPolicy / Policy
|
Modern CEL ValidatingPolicy
|
|---|---|---|
| Kinds | One kind holds validate, mutate, and generate rules | A separate kind per purpose: Validating, Mutating, Generating, Deleting, ImageValidating |
| Shape |
spec.rules[], each with match and a validate or mutate block |
matchConstraints once, then validations[] with a CEL expression and a message (modeled on Kubernetesβ ValidatingAdmissionPolicy) |
| Matching | Kinds like Pod, plus names, selectors, and more |
API groups, versions, operations, and plural resources like pods
|
| Scope |
ClusterPolicy cluster-wide, Policy per namespace |
ValidatingPolicy cluster-wide, NamespacedValidatingPolicy per namespace |
| Enforcement | Failure action (Enforce/Audit) | Validation actions (Deny/Audit/Warn) |
6. Takeaways & Next Steps
Transitioning from traditional Kyverno ClusterPolicy manifests to modern CEL-based ValidatingPolicy represents a significant architectural step forward:
-
Standardization: CEL rules use the same expression language and a similar structure as Kubernetes' own
ValidatingAdmissionPolicyrather than relying on custom pattern-matching dialects. - Efficiency: CEL provides deterministic typing and a standard expression language, replacing Kyverno's custom pattern-matching dialect.
What I Haven't Tested
-
Policy reports: Using Kyverno's
PolicyReportandClusterPolicyReportCRDs to aggregate and query validation results over time. -
Native
ValidatingAdmissionPolicygeneration: Generating upstream KubernetesValidatingAdmissionPolicyresources from Kyverno CEL policies (viakyverno generate).installed Kyverno via Helm, using chart version kyverno-3.9.2 (App version v1.19.2) that supports CEL policies.
Out of the box, Kubernetes has no idea what a Kyverno policy is. To verify the installation, I checked if Kyverno successfully registered its Custom Resource Definitions (CRDs), which extend the Kubernetes API so it can understand our custom policy objects:
kubectl get crd | findstr "policies.kyverno.io"
Output:
cleanuppolicies.kyverno.io Namespaced v2(storage),v2beta1 2026-10-24T15:40:36Z
clustercleanuppolicies.kyverno.io Cluster v2(storage),v2beta1 2026-10-24T15:40:36Z
clusterpolicies.kyverno.io Cluster v1(storage),v2beta1 2026-10-24T15:40:37Z
deletingpolicies.policies.kyverno.io Cluster v1,v1alpha1,v1beta1(storage) 2026-10-24T15:40:36Z
generatingpolicies.policies.kyverno.io Cluster v1,v1alpha1,v1beta1(storage) 2026-10-24T15:40:36Z
imagevalidatingpolicies.policies.kyverno.io Cluster v1,v1alpha1,v1beta1(storage) 2026-10-24T15:40:36Z
mutatingpolicies.policies.kyverno.io Cluster v1,v1alpha1,v1beta1(storage) 2026-10-24T15:40:36Z
namespaceddeletingpolicies.policies.kyverno.io Namespaced v1,v1beta1(storage) 2026-10-24T15:40:36Z
namespacedgeneratingpolicies.policies.kyverno.io Namespaced v1,v1beta1(storage) 2026-10-24T15:40:36Z
namespacedimagevalidatingpolicies.policies.kyverno.io Namespaced v1,v1beta1(storage) 2026-10-24T15:40:36Z
namespacedmutatingpolicies.policies.kyverno.io Namespaced v1,v1beta1(storage) 2026-10-24T15:40:36Z
namespacedvalidatingpolicies.policies.kyverno.io Namespaced v1,v1beta1(storage) 2026-10-24T15:40:36Z
policies.kyverno.io Namespaced v1(storage),v2beta1 2026-10-24T15:40:37Z
policyexceptions.policies.kyverno.io Namespaced v1,v1alpha1,v1beta1(storage) 2026-10-24T15:40:36Z
validatingpolicies.policies.kyverno.io Cluster v1,v1alpha1,v1beta1(storage) 2026-10-24T15:40:36Z
Seeing validatingpolicies.policies.kyverno.io in the output confirmed the CRDs were installed.
- Policy v1: The Legacy ClusterPolicy Before trying CEL, I wanted to see how this is traditionally done. In older versions of Kyverno, you create a ClusterPolicy that relies on Kyverno's custom pattern-matching engine.
Based on the official Kyverno Policy Library, a basic policy to enforce a label looks like this:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-team-label
spec:
validationFailureAction: Enforce
rules:
- name: check-team-label
match:
any:
- resources:
kinds:
- Pod
validate:
message: "The label
teamis required." pattern: metadata: labels: team: "?*"
- Pod
validate:
message: "The label
- resources:
kinds:
Notice the syntax: it tells the Kyverno controller to watch for Pod resources, and it uses a custom wildcard "?*" to say "this label must exist and have a value."
When I applied this policy and tried to create an unlabelled pod, the Kyverno controller caught it and blocked the request, sending back this rejection message:
Error from server: admission webhook "validate.kyverno.svc-fail" denied the request:
resource Pod/default/c1 was blocked due to the following policies
require-team-label:
check-team-label: 'validation error: The label team is required. rule check-team-label failed at path /metadata/labels/team/'
- Policy v2: The CEL ValidatingPolicy Next, I deleted the legacy ClusterPolicy and replaced it with a modern ValidatingPolicy written in Common Expression Language (CEL).
Here is the exact ValidatingPolicy manifest I applied to the cluster:
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: require-team-label
spec:
validationActions:
- Deny
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
validations:
- expression: "has(object.metadata.labels) && 'team' in object.metadata.labels"
message: "The label team is required."
Breaking Down the CEL Logic
The core of this policy is the CEL expression: has(object.metadata.labels) && 'team' in object.metadata.labels
has(object.metadata.labels) safely checks if the incoming Pod object has any labels attached at all (preventing a null reference error).
'team' in object.metadata.labels evaluates whether the specific key team exists within that metadata map.
Testing the CEL Policy
Negative Test (Missing Label):
First, I attempted to launch an unlabelled Pod:
kubectl run c1 --image=nginx
As expected, the request was immediately denied by the admission webhook:
Error from server: admission webhook "vpol.validate.kyverno.svc-fail" denied the request: Policy require-team-label failed: The label team is required.
Positive Test (Valid Label Attached):
Next, I ran the test command providing the required label:
kubectl run c2 --image=nginx --labels=team=payments
Output:
pod/c2 created
- Comparison: What Changed? Having deployed and tested both, the shift from traditional Kyverno policies to CEL-based validation introduces some major architectural changes. Here is a quick breakdown of the core differences:
Feature Legacy ClusterPolicy / Policy Modern CEL ValidatingPolicy
Kinds One kind holds validate, mutate, and generate rules A separate kind per purpose: Validating, Mutating, Generating, Deleting, ImageValidating
Shape spec.rules[], each with match and a validate or mutate block matchConstraints once, then validations[] with a CEL expression and a message (modeled on Kubernetesβ ValidatingAdmissionPolicy)
Matching Kinds like Pod, plus names, selectors, and more API groups, versions, operations, and plural resources like pods
Scope ClusterPolicy cluster-wide, Policy per namespace ValidatingPolicy cluster-wide, NamespacedValidatingPolicy per namespace
Enforcement Failure action (Enforce/Audit) Validation actions (Deny/Audit/Warn)
- Takeaways & Next Steps Transitioning from traditional Kyverno ClusterPolicy manifests to modern CEL-based ValidatingPolicy represents a significant architectural step forward:
Standardization: CEL rules use the same expression language and a similar structure as Kubernetes' own ValidatingAdmissionPolicy rather than relying on custom pattern-matching dialects.
Efficiency: CEL provides deterministic typing and a standard expression language, replacing Kyverno's custom pattern-matching dialect.
What I Haven't Tested
Policy reports: Using Kyverno's PolicyReport and ClusterPolicyReport CRDs to aggregate and query validation results over time.
Native ValidatingAdmissionPolicy generation: Generating upstream Kubernetes ValidatingAdmissionPolicy resources from Kyverno CEL policies (via kyverno generate).
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.