If you run Kyverno, you have a deadline. Kyverno 1.19 shipped in August 2026 and officially deprecated the ClusterPolicy and Policy resources that most people’s policy libraries are built on. Those two kinds are scheduled for removal in v1.20.
In their place are five dedicated CEL based policy types: ValidatingPolicy, MutatingPolicy, GeneratingPolicy, ImageValidatingPolicy and DeletingPolicy. As of 1.19 they have full feature parity with the old rule based model, which means there is no longer a reason to hold off on the migration.
This tutorial walks through the new model hands on. We will install Kyverno on a local cluster, write validating, mutating and image policies from scratch, take an existing ClusterPolicy and rewrite it, and set up policy tests that run in CI. By the end you will have a migration pattern you can apply to your own policy library.
Why Kyverno moved to CEL
The original Kyverno model let you write policies as YAML patterns. It was approachable, and that is a large part of why Kyverno got popular. It also had limits. Complex conditions turned into nested preconditions blocks and JMESPath expressions that were hard to read and harder to review.
CEL, the Common Expression Language, is the same expression language Kubernetes itself adopted for ValidatingAdmissionPolicy and CRD validation rules. Moving to CEL gets Kyverno three things.
- Alignment with upstream Kubernetes. The expressions you write in a Kyverno ValidatingPolicy are the same expressions you would write in a native ValidatingAdmissionPolicy. Skills transfer in both directions.
- Better performance. CEL expressions are compiled once rather than interpreted per request, which matters on a busy admission path.
- Clearer intent. One expression per rule with its own message, instead of a pattern you have to mentally diff against the incoming object.
The trade off is that CEL is a real expression language with its own semantics around optionals and null handling. That is the main thing to learn, and we will cover the patterns that come up most.
What you need
- A Kubernetes cluster on 1.32 or newer. Kind or minikube is fine.
- kubectl and Helm 3
- The Kyverno CLI, for testing policies without a cluster
- About 30 minutes
Step 1: Install Kyverno
kind create cluster --name kyverno-lab
helm repo add kyverno https://kyverno.github.io/kyverno/
helm repo update
helm install kyverno kyverno/kyverno \
--namespace kyverno \
--create-namespace
kubectl -n kyverno wait --for=condition=Available deployment --all --timeout=300s
Before you write anything, confirm which API version your install serves. The CEL policy types moved through v1alpha1 and v1beta1 on their way to v1, so check rather than copying an apiVersion from a blog post, including this one.
kubectl api-resources | grep kyverno
kubectl api-versions | grep kyverno
Use whatever version policies.kyverno.io reports in the examples below. The examples here use policies.kyverno.io/v1.
Step 2: Your first ValidatingPolicy
The classic starter policy is requiring labels. Every platform team ends up with some version of this, usually for cost allocation or ownership.
cat > require-labels.yaml <<'EOF'
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: "'team' in object.metadata.?labels.orValue([])"
message: "Every pod must carry a 'team' label."
EOF
kubectl apply -f require-labels.yaml
Three parts matter here.
matchConstraints.resourceRulesdecides what the policy sees. This is the same shape as the native Kubernetes admission policy match rules.validationsis a list, and each entry has its own expression and its own message. Users get a specific reason, not a wall of text.validationActionsdecides what happens on failure.Denyblocks the request.Auditrecords a policy report entry and lets it through.Warnreturns a warning to the client.
Note the .?labels.orValue([]) pattern. Labels can be absent, and CEL will error rather than return null if you index into a missing field. The optional accessor plus orValue gives you a safe default. You will use this constantly.
Test it:
# should be rejected
kubectl run nolabel --image=nginx:alpine
# should be accepted
kubectl run haslabel --image=nginx:alpine --labels=team=platform
The first command fails with your message. That fast feedback loop, with a message you wrote, is a big part of why policy as code lands well with application teams.
Step 3: Roll out in Audit before you Deny
Never ship a new deny policy straight to a live cluster. Start in Audit, look at what it would have blocked, then flip it.
cat > require-resources.yaml <<'EOF'
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: require-resource-limits
spec:
validationActions:
- Audit
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
validations:
- expression: |
object.spec.containers.all(c,
has(c.resources) &&
has(c.resources.limits) &&
'memory' in c.resources.limits
)
message: "Every container needs a memory limit."
- expression: |
object.spec.containers.all(c,
has(c.resources) &&
has(c.resources.requests) &&
'cpu' in c.resources.requests
)
message: "Every container needs a CPU request."
EOF
kubectl apply -f require-resources.yaml
Now deploy something that violates it and read the report instead of getting an error:
kubectl run greedy --image=nginx:alpine --labels=team=platform
kubectl get policyreports -A
kubectl get policyreport -o yaml | grep -A4 "result: fail"
Policy reports are the part teams underuse. Run every new policy in Audit for a week, export the failing resources, send the list to the owning teams, and only then change validationActions to Deny. That single habit prevents most of the friction that gives policy engines a bad name.
Step 4: MutatingPolicy for sensible defaults
Blocking is only half the job. Often the better answer is to fill in the safe default rather than reject the request. Mutation used to be a mutate rule inside a ClusterPolicy. It is now its own kind.
cat > default-securitycontext.yaml <<'EOF'
apiVersion: policies.kyverno.io/v1
kind: MutatingPolicy
metadata:
name: default-pod-security-context
spec:
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE"]
resources: ["pods"]
mutations:
- patchType: ApplyConfiguration
applyConfiguration:
expression: |
Object{
spec: Object.spec{
securityContext: Object.spec.securityContext{
runAsNonRoot: true,
seccompProfile: Object.spec.securityContext.seccompProfile{
type: "RuntimeDefault"
}
}
}
}
EOF
kubectl apply -f default-securitycontext.yaml
The ApplyConfiguration patch type builds a partial object with CEL and server side applies it. It reads much better than a JSON patch, and because it is a merge you are not replacing fields the user already set.
kubectl run defaulted --image=nginx:alpine --labels=team=platform
kubectl get pod defaulted -o jsonpath='{.spec.securityContext}' | jq
One rule of thumb: mutate for defaults, validate for guarantees. If you mutate something and also want to be certain it stays that way, write both policies. Mutation runs before validation in the admission chain, so the pair works.
Step 5: Controlling where images come from
The simplest useful supply chain policy is a registry allowlist, which stops the most common problem: someone deploying straight from a public registry into production.
cat > allowed-registries.yaml <<'EOF'
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: allowed-registries
spec:
validationActions:
- Deny
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
validations:
- expression: |
object.spec.containers.all(c,
c.image.startsWith('registry.internal.example.com/') ||
c.image.startsWith('ghcr.io/collabnix/')
)
message: "Images must come from the internal registry or ghcr.io/collabnix."
EOF
kubectl apply -f allowed-registries.yaml
For actual signature verification you use ImageValidatingPolicy, which replaces the old verifyImages rules and can check Cosign or Notary signatures and attestations against a public key or a keyless identity. That needs a real signed image to demo properly, so treat the allowlist above as the entry point and the signature policy as the next step once your build pipeline is signing.
Worth knowing: an allowlist on its own is weak if you also allow mutable tags. Pair it with a policy that requires digests for anything running in production namespaces.
Step 6: Migrating an existing ClusterPolicy
Here is a typical old style policy, the kind sitting in most repos today:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: disallow-latest-tag
spec:
validationFailureAction: Enforce
rules:
- name: require-image-tag
match:
any:
- resources:
kinds:
- Pod
validate:
message: "An image tag is required."
pattern:
spec:
containers:
- image: "*:*"
And the same thing as a ValidatingPolicy:
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: disallow-latest-tag
spec:
validationActions:
- Deny
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
validations:
- expression: |
object.spec.containers.all(c, c.image.contains(':'))
message: "An image tag is required."
- expression: |
object.spec.containers.all(c, !c.image.endsWith(':latest'))
message: "The 'latest' tag is not allowed."
The mapping is mostly mechanical once you see it:
| Old (ClusterPolicy) | New (CEL policy types) |
|---|---|
spec.rules[].match.any[].resources | spec.matchConstraints.resourceRules |
spec.validationFailureAction: Enforce | spec.validationActions: [Deny] |
spec.validationFailureAction: Audit | spec.validationActions: [Audit] |
validate.pattern or validate.deny | spec.validations[].expression |
validate.message | spec.validations[].message |
preconditions | fold into the CEL expression, or use matchConditions |
mutate rule | MutatingPolicy |
generate rule | GeneratingPolicy |
verifyImages rule | ImageValidatingPolicy |
CleanupPolicy | DeletingPolicy |
One structural difference to plan for. An old ClusterPolicy could hold many rules of mixed types in a single object. The new model splits by type, so one ClusterPolicy with a validate rule and a mutate rule becomes two objects. Your directory layout and your Helm chart will need adjusting, not just the YAML.
Each type also has a namespaced variant, for example NamespacedValidatingPolicy. If you have been giving teams their own policies through a shared cluster policy with namespace selectors, the namespaced kinds are usually a cleaner fit.
Step 7: Test policies in CI, not in production
The Kyverno CLI runs policies against manifests without a cluster, which means your policy repo can have real tests.
# apply a policy to a manifest and see the result
kyverno apply require-labels.yaml --resource test-pod.yaml
# run a declarative test suite
kyverno test .
A test file looks like this:
apiVersion: cli.kyverno.io/v1alpha1
kind: Test
metadata:
name: require-team-label-test
policies:
- require-labels.yaml
resources:
- test-pod.yaml
results:
- policy: require-team-label
resource: nolabel
kind: Pod
result: fail
- policy: require-team-label
resource: haslabel
kind: Pod
result: pass
Wire kyverno test . into your pull request checks. Policies are code that can block every deployment in the company, so they deserve the same review and test discipline as anything else you ship. This also gives you a safe way to do the 1.19 migration: port a policy, run the existing test suite against the new version, and confirm the results are identical before you delete the old one.
CEL patterns you will use constantly
# safe access to an optional map
'team' in object.metadata.?labels.orValue([])
# every container satisfies a condition
object.spec.containers.all(c, has(c.resources))
# at least one container satisfies a condition
object.spec.containers.exists(c, c.name == 'sidecar')
# include init and ephemeral containers too
(object.spec.containers +
object.spec.?initContainers.orValue([]) +
object.spec.?ephemeralContainers.orValue([])
).all(c, !c.image.endsWith(':latest'))
# only apply to a set of namespaces
object.metadata.namespace in ['prod', 'prod-eu']
# compare old and new on UPDATE
has(oldObject) ? object.spec.replicas >= oldObject.spec.replicas : true
That fourth one catches a common miss. Policies written only against spec.containers silently ignore init containers, and an init container is just as capable of running an unpinned or unapproved image.
Troubleshooting
Policy applies but nothing is blocked
Check validationActions. If it is Audit, results only appear in policy reports. Also check that matchConstraints actually matches. A common miss is writing a policy for pods and then testing with a Deployment, expecting the Deployment itself to be rejected. The pod template is what creates the Pod, so the failure surfaces on the ReplicaSet rather than at kubectl apply time.
CEL compile errors on apply
Kyverno validates expressions when you create the policy, so a compile error means the policy never took effect. Read the message carefully. Most of these are either a missing has() guard or an attempt to index into a field that may not exist.
Everything breaks after enabling a Deny policy
Exclude system namespaces. Controllers in kube-system create pods that will not satisfy your application policies, and blocking them can wedge the cluster. Scope with matchConditions or a namespace selector, and exclude kube-system deliberately.
Old and new policies both firing
During migration you may have a ClusterPolicy and a ValidatingPolicy covering the same rule, which produces duplicate messages. Run the new one in Audit, compare its reports against the old policy’s, and delete the old one only once they agree.
Cleanup
kind delete cluster --name kyverno-lab
Wrapping up
The Kyverno 1.19 deprecation is not a suggestion. ClusterPolicy and Policy go away in 1.20, so every policy you own needs porting.
The good news is that the port is mechanical for most policies. The match block maps over almost directly, and the majority of validate patterns become one or two CEL expressions that are easier to read than what they replace. Where it gets interesting is anything using preconditions or variable substitution, and those are the ones to tackle first while you still have time.
A sensible order of work: get the Kyverno CLI test suite running against your current policies, port one policy at a time, confirm the tests still pass, run the new policy in Audit alongside the old one, then remove the old one. Slow, but nobody gets paged.