Tools · Kyverno

Kyverno

The KCA blueprint puts 32% of its weight — more than the next two domains combined — on the single competency of writing policies, and shows the four rule types worked together as one file. This page pulls them apart again: each rule type gets its own worked example, its own gotcha, and enough surrounding architecture to explain why it behaves the way it does — the four controllers behind the engine, how a validate rule's writ never quite reaches a resource that already existed until a background scan says so, how generate keeps a promise long after the namespace that triggered it was created, and what a PolicyException actually buys you that editing the policy doesn't. The admission-control model underneath all of it — two windows, mutating then validating, before anything reaches etcd — belongs to every engine that can occupy those slots, not just this one, and has its own page; this one assumes that model and gets straight to Kyverno's specific fields.

☺ Explain it like I'm 10

Picture a toy factory's final inspection station, right before a box leaves the line. One inspector has four completely different jobs, and does exactly one of them per toy. Sometimes she rejects a toy outright and sends it back with a note explaining why. Sometimes she doesn't reject it — she just sticks on the safety label the toy was missing and waves it through, fixed. Sometimes a toy triggers her to print a brand-new accessory that belongs with it, like a matching charging cable, and drop that in the box too. And sometimes her only job is holding the toy's factory seal under a UV lamp to make sure it's real before anything else happens. Four jobs, one inspector, one toy at a time — and every single toy that leaves the factory passed through her station first. That inspector is Kyverno, and the four jobs are validate, mutate, generate, and verifyImages.

🐢Your host for this topic: Timmy the Turtle — the guardrail. Timmy refuses to let a manifest through until a policy has actually checked it, which is Kyverno's entire reason to exist.

Architecture: four controllers, and a webhook that grows itself

☺ Like you're 10: One crew member stands at the door checking everyone as they arrive; a second one quietly re-checks everyone already inside; a third writes up what both of them found; a fourth just tidies up on a schedule.

A modern Kyverno install splits the work across four separate controllers, and knowing which owns which job turns a confusing outage into a fast diagnosis. The admission controller serves the mutating and validating webhooks and is the only one actually sitting in the live request path — scale it first, and give it a PodDisruptionBudget, because its availability is now part of your cluster's availability. The background controller does two unrelated-sounding jobs that share one property — neither happens at admission time: it periodically re-evaluates validate rules against resources that already existed before a policy arrived, and it drives every generate and mutate-existing rule, queuing the work as an internal UpdateRequest object before applying it. The reports controller aggregates results from both of the above into PolicyReport and ClusterPolicyReport objects. The cleanup controller is the odd one out — it doesn't touch admission or reports at all, it just runs CleanupPolicy and ClusterCleanupPolicy resources on their own cron schedule, deleting whatever matches.

One detail explains why Kyverno stays cheap at scale: it dynamically rewrites its own webhook configuration to cover only the resource kinds your currently installed policies actually reference. Install one policy about Pod, and the API server only ever calls Kyverno for Pod admission — every other kind never pays the latency cost of a call that would do nothing.

Request kubectl create / update Admission controller mutate → validate → verifyImages in the live request path Background controller re-scans existing objects drives generate / mutate-existing queues an UpdateRequest first AdmissionReport BackgroundScanReport Reports controller aggregates both report kinds PolicyReport namespaced · polr ClusterPolicyReport cluster-scoped · cpolr Cleanup controller CleanupPolicy / Cluster- CleanupPolicy, cron-driven deletes directly — no report
◆ Key idea

Kyverno's whole premise is that a policy is an ordinary Kubernetes resource — you write YAML shaped like the object it checks, register no separate language, and let a ClusterPolicy or Policy reconcile through Argo CD or Flux exactly like any other workload. The Policy-as-Code Philosophy page covers what that costs against OPA/Gatekeeper's general-purpose alternative; this page assumes you've already made — or inherited — that choice, and gets on with using it well.

The resource you write: ClusterPolicy, Policy, and how a rule gets selected

☺ Like you're 10: One rulebook works everywhere in the station; a smaller one only covers a single room — and every rule inside either one says exactly who it's checking before it says anything else.

Two kinds, identical schema, different reach. ClusterPolicy (cpol) applies across every namespace and is the only one that can match a cluster-scoped resource like Namespace at all — write a Policy targeting Namespace and it silently matches nothing, because a namespaced object can't reach outside its own namespace to begin with. Policy (pol) is confined to the namespace it lives in, the way a tenant is handed ownership of rules for their own space without needing cluster-wide access to write them.

Both hold spec.rules[], and every rule needs a matchany or all lists of filters on kinds, namespaces, names, label selectors, subjects and operations — with an optional exclude block that removes resources the way a spotlight removes shadow. preconditions sit beside match as a cheaper, JMESPath-driven gate for logic match can't express — skip this rule entirely unless X — and one precondition idiom is worth memorizing before you ever hit it in the wild: {{ request.operation || 'BACKGROUND' }}. A background scan carries no live admission request, so request.operation simply doesn't exist there; the || default catches that case and resolves to the literal string "BACKGROUND" instead of erroring the rule out. spec.backgroundtrue by default — is what lets the background controller pick a validate rule up at all for resources that predate the policy; set it to false deliberately for any rule that reads admission-only context like request.userInfo, because that context genuinely does not exist outside a live request and the rule would misbehave silently otherwise.

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: mission-control-baseline
spec:
  background: true                       # scan existing resources too — the default
  rules:
    - name: require-crew-label
      match:
        any:
          - resources:
              kinds: [Deployment, StatefulSet, DaemonSet]
      exclude:
        any:
          - resources: { namespaces: [kube-system, kyverno] }
      preconditions:
        all:
          - key: "{{ request.operation || 'BACKGROUND' }}"
            operator: AnyIn
            value: [CREATE, UPDATE, BACKGROUND]
      validate:
        failureAction: Enforce           # current per-rule field; older spec.validationFailureAction still appears in the wild
        message: "Workloads need metadata.labels.crew — which flight owns this?"
        pattern:
          metadata:
            labels:
              crew: "?*"                 # ?* = at least one character
              =(replaced-by): "?*"       # =() : conditional anchor — IF this key exists, it must match too

That last line is a conditional anchor: =(field) means "only enforce this sub-pattern if the field is actually present" — useful for optional metadata you want validated when it exists without making it mandatory. Two siblings show up constantly in the policy library: +(field), "add this if absent," which only means something inside a mutate patch, and X(field), the negation anchor — the field must not be present at all. Getting the match/exclude selector subtly wrong, not a broken anchor, is the single most common reason a policy "does nothing" in practice — always prove a new rule against a resource you know should fail before trusting one that reports clean.

The four rule types, each doing a genuinely different job

☺ Like you're 10: Reject it, quietly fix it, print something new to go with it, or check its seal — one rule only ever does one of these.

Every rule carries exactly one of the four. Below, each gets its own worked example rather than one shared file, because the fields that matter differ completely between them.

Validate — say no, with a message someone can act on

This is the rule type the exam and real incidents both reach for most. Three fields carry the actual behavior: failureAction: Enforce — without it, nothing is ever blocked, only recorded — a match that genuinely selects the right kinds, and a message a human can act on without opening a runbook. pattern does structural matching; deny.conditions expresses arbitrary boolean logic pattern can't, and newer Kyverno also accepts a cel block using the same Common Expression Language Kubernetes itself uses natively in ValidatingAdmissionPolicy — worth learning once, because it transfers directly.

# deny with explicit conditions — reject the ':latest' tag outright
- name: block-latest-tag
  match:
    any:
      - resources: { kinds: [Pod] }
  validate:
    failureAction: Enforce
    message: "The ':latest' tag is not allowed — pin a version or a digest."
    deny:
      conditions:
        any:
          - key: "{{ images.containers.*.tag }}"
            operator: AnyIn
            value: ["latest"]

Mutate — fix it instead of rejecting it

Mutation turns Kyverno from a bouncer into a butler: instead of bouncing a request back for a missing field, it patches the field in and lets the request through. patchStrategicMerge reads like the manifest it's altering; patchesJson6902 is an RFC 6902 JSON patch for anything a strategic merge can't express, like inserting into a specific array index. A separate targets block lets a mutate rule act on something other than the object that triggered it — "mutate existing" — and the spec-level mutateExistingOnPolicyUpdate: true makes that fire again whenever the policy itself changes, not only when the trigger does.

- name: default-pull-policy
  match:
    any:
      - resources: { kinds: [Pod] }
  mutate:
    patchesJson6902: |-
      - op: add
        path: "/spec/containers/0/imagePullPolicy"
        value: IfNotPresent

- name: add-managed-by-label
  match:
    any:
      - resources: { kinds: [Deployment, StatefulSet] }
  mutate:
    patchStrategicMerge:
      metadata:
        labels:
          +(app.kubernetes.io/managed-by): mission-control   # +() = add only if absent

Generate — bootstrap something automatically

generate creates a companion resource the moment a trigger appears — the canonical, genuinely valuable example being a default-deny NetworkPolicy for every new namespace, so tenant isolation is the default instead of a task someone forgets in week one. data defines the resource inline; clone copies an existing object instead — the right shape for a registry pull secret you maintain in exactly one place. synchronize: true keeps the generated copy in lockstep with its source going forward, on both sides of the relationship.

- name: default-deny-per-namespace
  match:
    any:
      - resources: { kinds: [Namespace] }          # cluster-scoped target → must be a ClusterPolicy
  exclude:
    any:
      - resources: { names: [kube-system, kube-public, kyverno] }
  generate:
    apiVersion: networking.k8s.io/v1
    kind: NetworkPolicy
    name: default-deny
    namespace: "{{ request.object.metadata.name }}"  # a variable, resolved at runtime
    synchronize: true
    data:
      spec:
        podSelector: {}
        policyTypes: [Ingress, Egress]

- name: copy-registry-credentials
  match:
    any:
      - resources: { kinds: [Namespace] }
  generate:
    apiVersion: v1
    kind: Secret
    name: registry-credentials
    namespace: "{{ request.object.metadata.name }}"
    synchronize: true
    clone:                       # clone an existing object — never inline a secret in policy YAML
      namespace: platform-system
      name: registry-credentials
Secret · registry-credentials ns: platform-system (source) Secret · registry-credentials ns: payments (synced copy) clone + synchronize: true Edit the source → the copy updates too Delete the copy by hand → Kyverno recreates it Delete the ClusterPolicy → every copy it made is deleted, in every namespace, at once "synchronize" ties the copy to the source AND to the policy that generated it.

verifyImages — trust the seal, not the tag

This is where Kyverno meets the software supply chain: no valid signature, no pod. Keyless verification is the modern default — instead of managing a public key, you assert who signed the image and which OIDC issuer vouched for that identity, checked against the Fulcio certificate authority and the Rekor transparency log the same way Cosign & Sigstore covers in full. mutateDigest: true is on for a reason: a successful verification rewrites the tag to the exact digest that was just verified, which is why this rule type lives in the mutating phase rather than the validating one — closing the tag-mutation race where a tag gets re-pointed at a different image after the check ran.

- name: require-keyless-signature
  match:
    any:
      - resources: { kinds: [Pod] }
  verifyImages:
    - imageReferences: ["ghcr.io/mission-control/*"]  # scope it — never verify public base images this way
      mutateDigest: true              # rewrite tag -> digest once verified
      required: true
      attestors:
        - count: 1
          entries:
            - keyless:
                subject: "https://github.com/mission-control/*/.github/workflows/*"
                issuer: "https://token.actions.githubusercontent.com"
                rekor: { url: https://rekor.sigstore.dev }
⚠ webhookTimeoutSeconds is tighter than a registry round-trip

A verifyImages rule has to reach a registry and, for keyless verification, the Rekor log — real network calls the other three rule types never make. The default webhookTimeoutSeconds on a Kyverno webhook is short enough that a slow registry under load can blow straight through it, and the failure looks exactly like a broken policy rather than a slow dependency. Set webhookTimeoutSeconds deliberately higher on any policy carrying a verifyImages rule, and watch it under real load before trusting it in Enforce.

Autogen: one Pod rule quietly becomes five

Write a rule that matches Pod, and Kyverno doesn't stop there — it automatically generates equivalent rules, rewritten against .spec.template.spec, for the controllers that actually create pods: Deployment, StatefulSet, DaemonSet, Job, and CronJob. Without it, a developer who deploys a broken Deployment would see the Deployment accepted cleanly and only the pods underneath silently fail to schedule — a confusing failure with no useful message anywhere near where the person actually looked. Autogen makes the rejection land at the exact object the developer applied. It only fires for a rule that matches Pod specifically and doesn't already carry an explicit rule for that controller kind; steer or disable it entirely with the pod-policies.kyverno.io/autogen-controllers annotation on the policy. The generated rules carry an autogen- prefix in reports, which is worth knowing before it shows up unexplained in your first kubectl get polr.

Background scans, PolicyReports, and PolicyExceptions

☺ Like you're 10: A second pass quietly re-checks everything already inside, writes up what it found on a report card, and there's one very specific way to excuse a single student from a single rule without changing the rule for the whole class.

Background scanning is what closes the gap admission control structurally can't: a policy installed today has zero effect on the thousand resources already running, because admission only fires on CREATE and UPDATE. The background controller periodically re-evaluates every validate rule with background: true — the default — against the resources already sitting in the cluster, and writes what it finds into a BackgroundScanReport, which the reports controller folds into the same PolicyReport/ClusterPolicyReport objects admission-time results land in. This is the honest way to measure a fleet before you ever flip a rule to Enforce: switching enforcement on doesn't retroactively fix anything already running, it only blocks the next change to it — so read the report first, or you'll find out the hard way during an incident that two hundred resources were already in violation.

kubectl get polr -A -o wide       # PolicyReports — one per namespace, pass/fail/warn/skip/error
kubectl get cpolr                 # ClusterPolicyReports — the cluster-scoped equivalent
kubectl describe polr -n payments # per-resource, per-rule detail — read the FAIL column first

PolicyExceptions are the deliberate, reviewable escape hatch: one named workload skips one named rule, through Git and code review, while the policy stays strict for absolutely everyone else. They're disabled by default — a platform admin turns them on with --enablePolicyException=true on the admission controller and restricts where they can be created at all with --exceptionNamespaces, so an exception isn't something any tenant can self-grant from inside their own namespace.

apiVersion: kyverno.io/v2
kind: PolicyException
metadata:
  name: batch-runner-exempt
  namespace: platform-exceptions      # wherever --exceptionNamespaces allows
spec:
  exceptions:
    - policyName: mission-control-baseline
      ruleNames: [require-crew-label]
  match:
    any:
      - resources:
          kinds: [Deployment]
          namespaces: [analytics]
          names: [batch-runner]        # exactly one workload, never a whole namespace
✎ Try it

On a throwaway cluster, install Kyverno with Helm and apply mission-control-baseline above with failureAction: Audit. Create a Deployment with no crew label — it succeeds. Now run kubectl get polr -A -o wide and find your own violation sitting there, unblocked: the false comfort of audit mode, seen with your own eyes. Flip it to Enforce, try again, and read the rejection message. Then write a narrow PolicyException for one Deployment only, and confirm everything else in that namespace still gets rejected. The write-an-enforcing-policy drill is a guided version of exactly this exercise.

The kyverno CLI: apply, test, jp

☺ Like you're 10: Rehearse the rule against a stack of pretend toys before you ever let it near the real conveyor belt.

The standalone kyverno binary evaluates policies against manifests without a cluster, which is what makes changing a policy something you can safely do in a pull request rather than by hand against production. The curriculum names exactly three verbs worth having in muscle memory.

# apply — "what would these policies do to these resources?"
kyverno apply ./policies/ --resource ./manifests/
kyverno apply ./policies/ --resource ./manifests/ --policy-report
kyverno apply ./policies/ --cluster --policy-report     # dry-run against a LIVE cluster, nothing enforced

# test — declarative unit tests, discovered recursively as kyverno-test.yaml
kyverno test ./policies/

# jp — debug the expression language before it ever reaches a rule
kyverno jp query -i pod.yaml 'spec.containers[*].image'
kyverno jp function                                       # list Kyverno's custom JMESPath functions
# kyverno-test.yaml — pairs a policy with resources and asserts the expected result per rule
apiVersion: cli.kyverno.io/v1alpha1
kind: Test
metadata:
  name: mission-control-baseline-test
policies:
  - ../policies/mission-control-baseline.yaml
resources:
  - resources/good-deployment.yaml
  - resources/unlabeled-deployment.yaml
results:
  - policy: mission-control-baseline
    rule: require-crew-label
    resources: [good-deployment]
    kind: Deployment
    result: pass
  - policy: mission-control-baseline
    rule: require-crew-label
    resources: [unlabeled-deployment]
    kind: Deployment
    result: fail

Wire kyverno test into whatever pipeline guards the policy repository itself, and a policy that no longer catches what it claims to catch fails CI before it ever reaches a cluster — the same discipline applied to application code, applied here to governance.

Gotchas and failure modes

☺ Like you're 10: Most surprises come from the guard being unreachable, a permission nobody granted, two rules disagreeing about who goes first, or forgetting that the guard only checks things as they walk in, never things already standing there.

A Kyverno outage can become a cluster outage

Kyverno sits in the API server's request path, so its own availability quietly becomes part of your cluster's. failurePolicy on its webhook configuration decides what happens when Kyverno can't be reached at all: Fail rejects the request — secure, but a Kyverno outage now blocks every matching write, including, in the worst case, the very fix that would bring Kyverno back. Ignore lets the request through unchecked — available, but your guardrail disappears at precisely the moment things are already going wrong. Run enough admission-controller replicas to survive a node loss, and always exclude kube-system and Kyverno's own namespace from anything that could plausibly block Kyverno fixing itself.

Generate needs RBAC you have to grant explicitly

Kyverno cannot create a resource it holds no permission for. Generating a plain NetworkPolicy works out of the box; generating a custom resource owned by another operator usually doesn't, and the symptom is deceptively quiet — the policy reports healthy, the rule shows no error, and nothing ever appears. The fix is a ClusterRole, aggregated into the background controller's role, naming exactly the group/kind it needs to create. This is ordinary Kubernetes RBAC, not a Kyverno-specific mechanism — the sibling Kubernetes course's CKS material covers the RBAC model itself in depth if it's rusty.

Enforcement never applies retroactively

Flipping a rule from Audit to Enforce does not touch the resources already running in violation of it — admission control only ever fires on the next create or update. The background scan will keep reporting those violations forever unless someone actually fixes or exempts them; it does not, and cannot, reach in and correct a resource that's already live. Read the PolicyReport before every enforcement flip, not after.

Ordering across policies is not something to depend on

Rules within one policy run in the order they're written, but ordering across separate ClusterPolicy objects carries no such guarantee — never design a rule whose correctness depends on another policy having already run first. Kyverno also shares the mutating-webhook chain with anything else registered on the same kinds, a mesh sidecar injector among them; depending on call order, a validate rule may or may not ever see a container another webhook added afterward.

Kyverno vs the alternatives

☺ Like you're 10: A few different kinds of guard exist for the same door. They trade off how much you have to teach them, how far past this one cluster they can see, and whether they even need to stand at the door at all.

OptionLanguageCan mutate / generateRuns a webhookChoose it when…
KyvernoKubernetes YAML, plus optional CEL inside validateYes / Yes — both first-class rule typesYesThe team is already fluent in Kubernetes manifests and stays Kubernetes-only; you want generate and image verification built in, not bolted on
OPA / GatekeeperRego — general-purpose, standaloneMutation via a separate object; no native generateYesThe same rule logic needs to gate CI, Terraform or a service mesh too, not only Kubernetes admission
ValidatingAdmissionPolicyCEL, native to the API serverNo mutation, no generateNo — evaluated in-processA simple, hot-path check where removing a network hop from every request matters more than expressiveness. GA since Kubernetes 1.30
MutatingAdmissionPolicyCEL, native to the API serverMutation only; no generateNo — evaluated in-processThe CEL-native mutation counterpart to the above — reaching GA in Kubernetes 1.36; check what your control plane actually runs before relying on it

The practical rule most platforms land on: reach for Kyverno first when the guardrails only ever need to look at Kubernetes objects, because generate and verifyImages turn governance into a paved road instead of a wall, and nothing else on this table offers both natively. Reach for OPA/Gatekeeper specifically when the same policy brain has to reason about something that isn't a Kubernetes object at all — Policy-as-Code Philosophy makes that fork the center of its argument, in depth. And treat the native CEL policies as a scalpel for the hot path, not a replacement for either engine: no generate, no image verification, on either of them, as of the versions this page was written against.

🎬 At Mission Control
🦊

Foxy: New namespaces stopped getting their default-deny NetworkPolicy three days ago. The generate rule is still installed, still shows healthy. I don't get it.

🦫

Benny the Beaver: Background controller logs say Forbidden — someone tightened its ClusterRole in Tuesday's RBAC cleanup and dropped the one line letting it create NetworkPolicies.

👺

Gizmo: Easy. Bind the background controller's service account to cluster-admin. Then it can never be short a permission again — problem solved forever! 🤑

🐢

Timmy the Turtle: Gizmo, we run a policy engine specifically to keep blast radius small. Handing it cluster-admin because one permission got dropped is trading a five-minute fix for the exact risk this whole page exists to prevent.

🦫

Benny the Beaver: One line back in the aggregated ClusterRole — create/patch on NetworkPolicy, nothing else — and it's healthy again.

🤖

Recon the Robot: And that ClusterRole edit goes through the same pull request as everything else I reconcile. Nobody hand-patches RBAC against a live cluster on my watch.

🐢 Timmy's checkpoint

1. Which of Kyverno's four controllers is the only one in the live request path, and what does that imply for how you run it in production? 2. Name the four rule types and say in one clause what each does. 3. What does the {{ request.operation || 'BACKGROUND' }} idiom protect against, and when would you actually need it? 4. Why doesn't flipping a rule from Audit to Enforce fix the resources already violating it? 5. What's the difference between data and clone in a generate rule, and what does synchronize: true add to either? 6. Why does verifyImages run in the mutating phase instead of the validating phase? 7. What does a PolicyException give you that simply editing the policy doesn't, and what two controls gate who can create one?

Check your answers
  1. The admission controller. Because everything else can be briefly unavailable without an outage, but the admission controller's own health becomes part of the cluster's health — run enough replicas to survive a node loss, and be deliberate about failurePolicy.
  2. validate — allow or reject the request; mutate — rewrite the object on its way in (or an existing one, via targets); generate — create a companion resource when a trigger appears; verifyImages — require a valid signature or attestation before a pod is admitted, and typically pin the tag to a digest.
  3. It protects a rule from breaking during a background scan, where there's no live admission request and request.operation would otherwise resolve to nothing. You need it on any precondition that references request.operation in a policy with background: true (the default).
  4. Because admission control only ever evaluates a resource on CREATE or UPDATE — it has no mechanism to reach into a resource that's already running and correct it. Enforcement only blocks the next change; the background scan keeps reporting existing violations until something actually fixes or exempts them.
  5. data defines the generated resource's content inline in the policy; clone copies an existing object instead, the right choice for something like a shared registry secret. synchronize: true keeps the generated copy tied to its source going forward — edits to the source propagate, a manually deleted copy gets recreated, and deleting the policy itself deletes every copy it ever generated.
  6. Because a successful verification rewrites the image tag to the exact digest just verified (mutateDigest) — pinning the reference closed against a later re-tag — and only a mutating webhook is permitted to change the object on its way through.
  7. A separate, reviewable resource: one named workload skips one named rule, through Git and code review, while the policy stays strict for everyone else, instead of permanently weakening the rule for the whole fleet. It's gated by --enablePolicyException (off by default) and --exceptionNamespaces, which restricts where an exception may even be created.