Skip to content
Go back

Kyverno: K8s Policy Without Rego

By KingPin 12 min read
Kyverno: K8s Policy Without Rego
Contents

Your Cluster Has No Bouncer

It’s 2 AM. A container with image: nginx:latest and privileged: true is running in your k3s cluster, and nobody remembers who deployed it. You didn’t. Probably.

An admission controller fixes this. It sits between kubectl apply and the API server and says no before the pod exists. For a home lab, my pick is Kyverno. Its policies are Kubernetes YAML plus a small expression language, and you can read a policy without a course. OPA Gatekeeper earns its place when you want one policy engine across Terraform, CI, and Kubernetes. If your world ends at the cluster edge, Gatekeeper is a forklift hired to move a couch. We covered that side in OPA & Gatekeeper: Policy as Code and OPA Rego Policy as Code.

One thing changed recently, and it affects every tutorial you’ll find. As of Kyverno v1.19 (August 2026), the ClusterPolicy type is officially deprecated and the docs schedule its removal in v1.20 (estimated November 2026). The replacements are new CEL-based types in the policies.kyverno.io API group. This post teaches those first.

“Without Rego” needs one footnote. You do learn a language: CEL, the Common Expression Language that Kubernetes itself uses in ValidatingAdmissionPolicy. It is smaller than Rego, and it is one you’d meet in the Kubernetes API anyway.

The Policy Types in One Table

Kyverno’s docs list these types as stable since v1.18, all at policies.kyverno.io/v1:

TypeJob
ValidatingPolicyAllow or reject resources
MutatingPolicyPatch new or existing resources
GeneratingPolicyCreate or clone resources
ImageValidatingPolicyVerify image signatures and attestations
DeletingPolicyDelete matching resources on a schedule

Each one has a namespaced twin (NamespacedValidatingPolicy and so on) that only touches its own namespace. That is handy when one app owner should manage rules for one namespace without cluster-admin rights.

The legacy ClusterPolicy, Policy, and CleanupPolicy kinds still work in v1.19 but print admission warnings. Per the docs, the CEL types have full feature parity as of v1.19.

Install Kyverno on k3s

Kyverno must live in its own namespace, never in kube-system. Kyverno 1.19 is tested on Kubernetes 1.33 to 1.35, so run a current k3s release. (The chart itself only refuses clusters older than 1.25.)

Terminal window
helm repo add kyverno https://kyverno.github.io/kyverno/
helm repo update
helm install kyverno kyverno/kyverno -n kyverno --create-namespace

That gives you four Deployments, one replica each: admission controller, background controller, reports controller, and cleanup controller. For a single-node lab, one replica each is fine. The docs’ HA example uses 3 admission replicas and 2 of each other controller, and you only want it when you have the nodes to spread them across:

Terminal window
helm install kyverno kyverno/kyverno -n kyverno --create-namespace \
--set admissionController.replicas=3 \
--set backgroundController.replicas=2 \
--set cleanupController.replicas=2 \
--set reportsController.replicas=2

Check what you got with kubectl get pods -n kyverno. The helm search repo kyverno/kyverno command shows the chart version next to the app version it ships.

Validate: Audit First, Enforce Later

Start with a policy that tells you what’s wrong without breaking anything. This one flags Deployments without a team label:

require-team-label.yaml
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: require-team-label
spec:
validationActions:
- Audit
matchConstraints:
resourceRules:
- apiGroups: ['apps']
apiVersions: ['v1']
operations: [CREATE, UPDATE]
resources: [deployments]
validations:
- message: "Deployments need a 'team' label"
expression: "has(object.metadata.labels) && 'team' in object.metadata.labels"

The expression is CEL. It returns true for a pass and false for a fail. object is the incoming resource. If you’ve written a Kubernetes ValidatingAdmissionPolicy, this is the same shape, and the Kyverno docs call ValidatingPolicy a superset of it.

validationActions is where Audit versus Enforce lives in the new types. Audit allows the resource and records the failure in a policy report. Deny blocks it. The old ClusterPolicy used validationFailureAction for this, then moved to a per-rule failureAction. The docs mark the policy-level spec.validationFailureAction as deprecated in favor of spec.rules[*].validate[*].failureAction. If you see either in a tutorial, you’re reading ClusterPolicy material.

Here is the blocking version. It rejects pods that use :latest or have no tag:

disallow-latest-tag.yaml
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:
- message: "Pin an image tag. ':latest' or no tag is not allowed."
expression: >-
object.spec.containers.all(c, c.image.contains(':') && !c.image.endsWith(':latest'))

Run kubectl apply -f on it, then try kubectl run test --image=nginx. You get your message back from the API server. Your 2 AM self will thank you.

One gotcha: a registry with a port (registry.lan:5000/app) contains a colon even with no tag, so this simple check passes it. For real use, parse the image reference properly in CEL. Treat the policy above as a starting point.

Policy Reports: The Audit Trail

Audit mode is only useful if you read the results. Kyverno writes standard wgpolicyk8s.io PolicyReports:

Terminal window
kubectl get policyreport -A
kubectl get clusterpolicyreport

Reports come from two triggers: admission events and background scans of existing resources. The chart’s default background scan interval is 1 hour. A report only reflects the current state of the cluster. Per the docs, resources blocked at admission do not show up as failures in reports, so check Kubernetes Events or the policy rule execution metric for those.

A good rollout looks like this: ship every new policy as Audit, wait for the background scan, read the report, fix or except the offenders, then flip to Deny.

Mutate: Fix It Instead of Rejecting It

Sometimes rejecting is rude. A MutatingPolicy patches the resource instead. This one stamps a label on every new pod:

add-managed-by.yaml
apiVersion: policies.kyverno.io/v1
kind: MutatingPolicy
metadata:
name: add-managed-by
spec:
matchConstraints:
resourceRules:
- apiGroups: ['']
apiVersions: ['v1']
operations: ['CREATE']
resources: ['pods']
mutations:
- patchType: ApplyConfiguration
applyConfiguration:
expression: >
Object{
metadata: Object.metadata{
labels: {"managed-by": "kyverno"}
}
}

ApplyConfiguration merges the object you describe into the incoming one, the same idea as server-side apply. Setting spec.evaluation.mutateExisting.enabled: true extends a policy to resources already in the cluster through background processing, so you don’t have to recreate them.

Generate: Namespaces That Arrive Configured

My favorite use for Kyverno at home is making new namespaces safe by default. A GeneratingPolicy watches for a trigger (a new Namespace) and creates a downstream resource (a default-deny NetworkPolicy):

default-deny-ingress.yaml
apiVersion: policies.kyverno.io/v1
kind: GeneratingPolicy
metadata:
name: default-deny-ingress
spec:
evaluation:
synchronize:
enabled: true
matchConstraints:
resourceRules:
- apiGroups: ['']
apiVersions: ['v1']
operations: ['CREATE']
resources: ['namespaces']
variables:
- name: nsName
expression: object.metadata.name
- name: downstream
expression: >-
[
{
"kind": dyn("NetworkPolicy"),
"apiVersion": dyn("networking.k8s.io/v1"),
"metadata": dyn({"name": "default-deny-ingress", "namespace": string(variables.nsName)}),
"spec": dyn({"podSelector": {}, "policyTypes": ["Ingress"]})
}
]
generate:
- expression: generator.Apply(variables.nsName, variables.downstream)

With synchronize enabled, Kyverno keeps the generated object in line with the policy. Someone deletes the NetworkPolicy by hand, and it comes back. Cloning works too: the docs’ example uses resource.Get("v1", "secrets", "default", "regcred") to copy a registry secret into each new namespace. Kyverno v1.19 also added YAML templates for generate entries (marked beta), which read better than the dyn() calls above for manifest-shaped output.

Verify Images: Only Run What You Signed

If you sign images in CI (see Cosign Keyless with GitHub OIDC and Trivy and Cosign), the cluster can enforce it. An ImageValidatingPolicy names who is allowed to sign:

verify-signed-images.yaml
apiVersion: policies.kyverno.io/v1
kind: ImageValidatingPolicy
metadata:
name: verify-signed-images
spec:
validationActions:
- Deny
matchConstraints:
resourceRules:
- apiGroups: ['']
apiVersions: ['v1']
operations: ['CREATE', 'UPDATE']
resources: ['pods']
matchImageReferences:
- glob: 'ghcr.io/example-user/*'
attestors:
- name: cosign
cosign:
keyless:
identities:
- subject: 'https://github.com/example-user/my-app/.github/workflows/release.yaml@refs/heads/main'
issuer: 'https://token.actions.githubusercontent.com'
ctlog:
url: 'https://rekor.sigstore.dev'
validations:
- expression: >-
images.containers.map(image, verifyImageSignatures(image, [attestors.cosign])).all(e, e > 0)
message: image must be signed by the release workflow

The matchImageReferences glob scopes the policy to your images, so the stock nginx pod is not caught. Adding validationConfigurations with mutateDigest: true rewrites tags to digests, which stops a tag from being repointed after verification. I did not run this one against a live registry. The structure follows the Kyverno docs’ keyless example, so test it in Audit first.

PolicyExceptions: The Escape Hatch

Every home lab has one weird workload that breaks the rules: a Plex pod that needs host networking, a legacy container with :latest baked in. Do not weaken the policy. Write an exception.

PolicyExceptions are off by default. Enable them in the chart and name the one namespace where they’re accepted:

Terminal window
helm upgrade kyverno kyverno/kyverno -n kyverno --reuse-values \
--set features.policyExceptions.enabled=true \
--set features.policyExceptions.namespace=policy-exceptions

Then create the exception in that namespace. A CEL expression picks the resource, and policyRefs names the policy:

allow-legacy-latest.yaml
apiVersion: policies.kyverno.io/v1
kind: PolicyException
metadata:
name: allow-legacy-latest
namespace: policy-exceptions
spec:
policyRefs:
- name: disallow-latest-tag
kind: ValidatingPolicy
matchConditions:
- name: only-legacy-pod
expression: "object.metadata.name == 'legacy-app'"

Matching resources skip the rule, and the policy report records a skip result that names the exception. The exception is its own file, so you can review and delete it without touching the policy. The old kyverno.io PolicyException is deprecated alongside ClusterPolicy and goes away in v1.20.

Test Policies in CI With the Kyverno CLI

Never find out a policy is broken by breaking your cluster. The CLI runs policies against plain YAML files, no cluster needed. I ran every example above with the v1.19.1 CLI image:

Terminal window
docker run --rm -v "$PWD":/w -w /w ghcr.io/kyverno/kyverno-cli:v1.19.1 \
apply disallow-latest-tag.yaml --resource pods.yaml

With one good pod and one nginx:latest pod, it printed pass: 1, fail: 1. For repeatable tests, write a kyverno-test.yaml that states the expected outcomes:

kyverno-test.yaml
apiVersion: cli.kyverno.io/v1alpha1
kind: Test
metadata:
name: disallow-latest-tag
policies:
- disallow-latest-tag.yaml
resources:
- pods.yaml
results:
- policy: disallow-latest-tag
resources:
- good
kind: Pod
result: pass
- policy: disallow-latest-tag
resources:
- bad
kind: Pod
result: fail

kyverno test . runs it, prints a table, and exits non-zero when an expectation is wrong. I flipped one expected result on purpose and got Error: 1 tests failed with exit code 1, which is what a CI step needs. Drop it into a GitHub Actions job (kyverno/action-install-cli installs the CLI) and every pull request tests your policies.

Two CLI flags matter now. apply accepts --exception to test exceptions: point a copy of the exception at 'bad' instead of 'legacy-app' and the bad pod flips from fail to skip. And --warnings-as-errors turns the ClusterPolicy deprecation warning into a failed build, so your CI tells you which policies still need migrating. Applying an old ClusterPolicy with the v1.19.1 CLI prints a warning pointing to the migration guide.

Kyverno vs Gatekeeper: Which One for the Lab?

KyvernoOPA Gatekeeper
Policy languageCEL, plus YAML structureRego
ScopeKubernetes, plus JSON payloads via evaluation.mode: JSONRego works across many tools
Mutate and generateBoth built inMutation is built in; no generate rules
Image verificationBuilt in (Cosign, Notary)Needs an add-on, such as an external data provider
InstallOne Helm chartOne Helm chart

Choose Kyverno if your policy lives and dies inside Kubernetes. Generate rules replace the namespace bootstrap script, and verify-image rules cover what Gatekeeper needs an external data provider for. Choose Gatekeeper if you already write Rego for Terraform or CI and want one language everywhere. Kyverno’s ValidatingPolicy can also check arbitrary JSON, such as a Terraform plan, but the Rego ecosystem for non-Kubernetes targets is larger.

Both are admission webhooks, so both share the same sharp edge: when the webhook is down, the failurePolicy decides what happens. See the questions below.

Migrating From ClusterPolicy

If you inherited ClusterPolicy YAML from older tutorials, you have until v1.20. The migration guide in the Kyverno docs maps each rule type to its new home. The shape change is the part to remember: a ClusterPolicy holds a list of rules of mixed types, while each CEL policy does one job. One ClusterPolicy with a validate rule and a mutate rule becomes a ValidatingPolicy and a MutatingPolicy. Use kubectl explain vpol.spec (or mpol, gpol, ivpol, dpol) to browse the new schemas from your terminal.

After upgrading to v1.19, one more detail from the docs: the storage version for the policies.kyverno.io types stays v1beta1 until v1.20, and the kyverno migrate CLI command rewrites stored objects afterward.

Common Questions

Can Kyverno and Gatekeeper run in the same cluster?

Yes. Both register their own admission webhooks, and the API server calls each one. A request must pass every validating webhook to be admitted. Running both adds a second engine to every admission request and doubles the maintenance, so pick one unless you are mid-migration from Gatekeeper to Kyverno.

What happens if Kyverno is down?

With failurePolicy: Fail, which Kyverno sets on its webhooks by default, the API server rejects in-scope requests once the webhook times out. That can block a cluster from creating pods while Kyverno is broken. Set failurePolicy: Ignore on policies you can afford to skip, or run multiple admission controller replicas.

Does Kyverno run on k3s?

Yes. Kyverno 1.19 supports Kubernetes 1.33 to 1.35, and k3s tracks upstream Kubernetes, so a current k3s release qualifies. The default install is four single-replica Deployments in the kyverno namespace. Check helm show values kyverno/kyverno for resource requests before sizing a small node.

Should I migrate from ClusterPolicy to the CEL policy types now?

Yes. The Kyverno docs deprecate ClusterPolicy in v1.19 and list removal in v1.20, estimated November 2026. The CEL types have full feature parity as of v1.19. Run kyverno apply --warnings-as-errors in CI to find every legacy policy, then convert them one at a time.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
Local LLM JSON Output That Doesn't Lie

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts