Overview
Kyverno runs as an admission controller plus background scanner. Policies are ordinary Kubernetes resources written in YAML, with the logic in CEL expressions (the language Kubernetes itself uses for ValidatingAdmissionPolicy).
Write new policies with the CEL policy types in policies.kyverno.io/v1 (stable since 1.18). ClusterPolicy, Policy and CleanupPolicy (kyverno.io) are deprecated in 1.19 and scheduled for removal in 1.20 (estimated November 2026). Each new type has a namespaced twin (NamespacedValidatingPolicy, …) that namespace owners can manage.
| Kind (short name) | Does | Replaces ClusterPolicy rule |
|---|---|---|
ValidatingPolicy (vpol) | Allow, audit or deny resources | validate |
MutatingPolicy (mpol) | Patch new or existing resources | mutate |
GeneratingPolicy (gpol) | Create or clone resources on a trigger | generate |
ImageValidatingPolicy (ivpol) | Verify image signatures and attestations | verifyImages |
DeletingPolicy (dpol) | Delete matching resources on a schedule | CleanupPolicy |
Instructions
Install
helm repo add kyverno https://kyverno.github.io/kyverno/ && helm repo update
# Production: three admission replicas (a single replica is fine for a test cluster)
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
# Optional: the Pod Security Standards as policies, Audit by default (more: https://kyverno.io/policies/)
helm install kyverno-policies kyverno/kyverno-policies -n kyverno --set podSecurityStandard=baseline
# CLI for local testing: package manager …
brew install kyverno # or: kubectl krew install kyverno -> kubectl kyverno
# … or a release archive, verified against the published checksums
VERSION=v1.19.1
curl -fsSLO https://github.com/kyverno/kyverno/releases/download/$VERSION/kyverno-cli_${VERSION}_linux_x86_64.tar.gz
curl -fsSLO https://github.com/kyverno/kyverno/releases/download/$VERSION/checksums.txt
sha256sum --check --ignore-missing checksums.txt # kyverno-cli_v1.19.1_linux_x86_64.tar.gz: OK
tar -xzf kyverno-cli_${VERSION}_linux_x86_64.tar.gz kyverno && install -D -m 0755 kyverno ~/.local/bin/kyverno
Validation Policies
# pod-hardening.yaml — limits required, no privileged containers, no :latest
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: pod-hardening
spec:
validationActions: [Audit] # Audit = report only, Warn = kubectl warning, Deny = block
matchConstraints:
resourceRules:
- {apiGroups: [''], apiVersions: [v1], operations: [CREATE, UPDATE], resources: [pods]}
variables:
- name: containers
expression: object.spec.containers + object.spec.?initContainers.orValue([])
validations:
- message: Every container needs CPU and memory limits.
expression: >-
variables.containers.all(c, has(c.resources) && has(c.resources.limits)
&& 'cpu' in c.resources.limits && 'memory' in c.resources.limits)
- message: Privileged containers are not allowed.
expression: variables.containers.all(c, !c.?securityContext.?privileged.orValue(false))
- messageExpression: "'Pin a version tag or digest instead of latest: ' + variables.containers.map(c, c.image).join(', ')"
expression: variables.containers.map(c, image(c.image)).all(i, i.containsDigest() || i.tag() != 'latest')
- A policy that matches only
podsis extended automatically to Deployments, ReplicaSets, StatefulSets, DaemonSets, Jobs and CronJobs (autogen); narrow it withspec.autogen.podControllers.controllers. - Optional fields need
has()or the?operator; a missing field otherwise raises an evaluation error.image()comes from Kyverno's CEL libraries (alsoresource.Get(),http.Get()); an untagged image counts aslatest. - Background scans of existing resources are on by default (
spec.evaluation.background.enabled); results land in PolicyReports:kubectl get policyreport -A.
Mutation Policies
# pod-defaults.yaml — security defaults, default requests, pull secret for the private registry
apiVersion: policies.kyverno.io/v1
kind: MutatingPolicy
metadata:
name: pod-defaults
spec:
autogen: {podControllers: {controllers: []}} # mutate Pods only (see the note below)
matchConstraints:
resourceRules:
- {apiGroups: [''], apiVersions: [v1], operations: [CREATE], resources: [pods]}
mutations:
- patchType: ApplyConfiguration # merge-style; list entries are matched by name
applyConfiguration:
expression: >-
Object{spec: Object.spec{
securityContext: Object.spec.securityContext{
runAsNonRoot: true,
seccompProfile: Object.spec.securityContext.seccompProfile{type: "RuntimeDefault"}
},
containers: object.spec.containers.map(c, Object.spec.containers{
name: c.name,
securityContext: Object.spec.containers.securityContext{allowPrivilegeEscalation: false},
resources: Object.spec.containers.resources{requests: Object.spec.containers.resources.requests{
cpu: c.?resources.?requests.?cpu.orValue(c.?resources.?limits.?cpu.orValue("100m")),
memory: c.?resources.?requests.?memory.orValue(c.?resources.?limits.?memory.orValue("256Mi"))
}}
})
}}
- patchType: JSONPatch # RFC 6902; required for atomic lists such as capabilities.drop (keeps an existing add list)
jsonPatch:
expression: >-
object.spec.containers.map(c, JSONPatch{op: "add",
path: "/spec/containers/" + string(object.spec.containers.indexOf(c)) + "/securityContext/capabilities",
value: {"drop": dyn(["ALL"]), "add": dyn(c.?securityContext.?capabilities.?add.orValue([]))}})
- patchType: JSONPatch
jsonPatch:
expression: >-
object.spec.containers.exists(c, c.image.startsWith("ghcr.io/northwind/"))
&& !has(object.spec.imagePullSecrets)
? [JSONPatch{op: "add", path: "/spec/imagePullSecrets", value: [{"name": "ghcr-northwind"}]}]
: []
ApplyConfiguration cannot change atomic lists or maps (the error is may not mutate atomic arrays, maps or structs), so use JSONPatch for those. Autogen rewrites ApplyConfiguration expressions for pod controllers but not JSONPatch paths: a Pod policy with a JSONPatch either disables autogen as above or fails on Deployments with no such key: containers. To patch resources that already exist, set spec.evaluation.mutateExisting.enabled: true and grant the background controller RBAC on the targets. Mutations run in order inside one policy; the order across policies is not guaranteed.
Generation Policies
# namespace-defaults.yaml — default-deny NetworkPolicy and a ResourceQuota in every new namespace
apiVersion: policies.kyverno.io/v1
kind: GeneratingPolicy
metadata:
name: namespace-defaults
spec:
evaluation:
synchronize: {enabled: true} # revert manual edits; follow policy changes
generateExisting: {enabled: true} # also cover namespaces that already exist
matchConstraints:
resourceRules:
- {apiGroups: [''], apiVersions: [v1], operations: [CREATE], resources: [namespaces]}
matchConditions:
- name: skip-system-namespaces
expression: "!(object.metadata.name in ['kube-system', 'kube-public', 'kyverno'])"
variables:
- name: ns
expression: object.metadata.name
- name: downstream
expression: >-
[
{"apiVersion": dyn("networking.k8s.io/v1"), "kind": dyn("NetworkPolicy"),
"metadata": dyn({"name": "default-deny-ingress"}),
"spec": dyn({"podSelector": dyn({}), "policyTypes": dyn(["Ingress"])})},
{"apiVersion": dyn("v1"), "kind": dyn("ResourceQuota"),
"metadata": dyn({"name": "default-quota"}),
"spec": dyn({"hard": dyn({"requests.cpu": "4", "requests.memory": "8Gi",
"limits.cpu": "8", "limits.memory": "16Gi", "pods": "50"})})}
]
generate:
- expression: generator.Apply(variables.ns, variables.downstream)
To clone instead of template, fetch the source with resource.Get("v1", "secrets", "platform", "ghcr-northwind") and pass it to generator.Apply(variables.ns, [variables.source]). With synchronize, deleting the trigger deletes the generated resources. The background controller needs RBAC for every kind it creates.
Verify Image Signatures
# verify-images.yaml — only images signed by the release workflow (cosign keyless) may run
apiVersion: policies.kyverno.io/v1
kind: ImageValidatingPolicy
metadata:
name: verify-northwind-images
spec:
validationActions: [Deny]
webhookConfiguration: {timeoutSeconds: 15} # default 10, maximum 30
matchConstraints:
resourceRules:
- {apiGroups: [''], apiVersions: [v1], operations: [CREATE, UPDATE], resources: [pods]}
matchImageReferences:
- glob: ghcr.io/northwind/*
attestors:
- name: ci
cosign:
keyless:
identities:
- subject: https://github.com/northwind/checkout-api/.github/workflows/release.yml@refs/heads/main
issuer: https://token.actions.githubusercontent.com
ctlog: {url: https://rekor.sigstore.dev}
validations:
- message: Image is not signed by the northwind release workflow.
expression: >- # every matched image must be verified, init containers included, or the Pod is rejected
(images.containers + images.initContainers + images.ephemeralContainers)
.all(i, verifyImageSignatures(i, [attestors.ci]) > 0)
Use subjectRegExp to accept several workflows, or cosign.key.data with a PEM public key. In the cluster the tag is rewritten to the verified digest (validationConfigurations.mutateDigest, default true). Private registries: list pull secrets under spec.credentials.secrets; they must live in the Kyverno namespace, and the Pod's own imagePullSecrets are not used. On the CLI, verification needs digest references (image:tag@sha256:…) and network access to the registry and Rekor.
Exceptions and Local Testing
# exception.yaml — needs the chart values features.policyExceptions.enabled=true and
# features.policyExceptions.namespace=policy-exceptions (exceptions are off by default)
apiVersion: policies.kyverno.io/v1
kind: PolicyException
metadata:
name: allow-node-exporter
namespace: policy-exceptions
spec:
policyRefs:
- {name: pod-hardening, kind: ValidatingPolicy}
matchConditions:
- name: node-exporter-pods
expression: object.metadata.namespace == 'monitoring' && object.metadata.name.startsWith('node-exporter')
expiresAt: "2027-03-31T00:00:00Z"
kyverno apply pod-hardening.yaml --resource manifests/ --table # dry run; exit code 1 on any failure
helm template charts/checkout-api | kyverno apply policies/ --resource - # rendered charts via stdin
kyverno apply pod-hardening.yaml --resource manifests/ --exception exception.yaml
kyverno apply policies/ --cluster --policy-report # scan the current kubectl context
kyverno test tests/ # runs every kyverno-test.yaml below tests/
# tests/kyverno-test.yaml — expected results, checked by `kyverno test`
apiVersion: cli.kyverno.io/v1alpha1
kind: Test
metadata: {name: pod-hardening}
policies: [../pod-hardening.yaml]
resources: [pods.yaml]
results:
- {policy: pod-hardening, isValidatingPolicy: true, kind: Pod, resources: [checkout-api], result: pass}
- {policy: pod-hardening, isValidatingPolicy: true, kind: Pod, resources: [debug-shell], result: fail}
Examples
Example 1: Stop :latest and missing limits without blocking anyone yet
User request: "Pods keep landing in our cluster with :latest and no limits. Add a Kyverno policy, but only report for now."
Save pod-hardening.yaml from above with validationActions: [Audit], then check the repository's manifests before touching the cluster:
$ kyverno apply pod-hardening.yaml --resource k8s/
Applying 1 policy rule(s) to 3 resource(s)...
policy pod-hardening -> resource payments/Pod/debug-shell failed:
1 - Pin a version tag or digest instead of latest: ghcr.io/northwind/debug-shell:latest
policy pod-hardening -> resource payments/Deployment/checkout-api failed:
1 - Every container needs CPU and memory limits.
pass: 1, fail: 2, warn: 0, error: 0, skip: 0
Then kubectl apply -f pod-hardening.yaml, confirm it with kubectl get vpol, read violations of running workloads with kubectl get policyreport -A, and change the action to [Deny] once the reports are clean.
Example 2: Migrate a ClusterPolicy before upgrading to 1.20
User request: "We still have this require-labels ClusterPolicy with a pattern. Convert it so the upgrade does not break us."
The legacy rule validate.pattern.metadata.labels: {team: "?*", app: "?*"} with validationFailureAction: Enforce becomes:
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: require-team-labels
spec:
validationActions: [Deny] # was validationFailureAction: Enforce
matchConstraints: # was match.any[].resources.kinds
resourceRules:
- {apiGroups: [apps], apiVersions: [v1], operations: [CREATE, UPDATE], resources: [deployments, statefulsets]}
validations: # was validate.pattern
- message: Deployments and StatefulSets need non-empty 'team' and 'app' labels.
expression: "['team', 'app'].all(l, object.metadata.?labels[l].orValue('') != '')"
Other mappings: preconditions → matchConditions, context → variables, {{ request.object }} → object, patchStrategicMerge → ApplyConfiguration, exclude → matchConstraints.excludeResourceRules. Run kyverno apply policies/ --resource k8s/ --warnings-as-errors in CI: it fails while any legacy policy remains (Error: found 1 deprecation warning(s) and --warnings-as-errors is set).
Guidelines
- Audit before Deny — start with
validationActions: [Audit], read the PolicyReports, then enforce. - Migrate before 1.20 — legacy
ClusterPolicy,CleanupPolicyandkyverno.ioPolicyExceptions stop working when the types are removed;kubectl get cpol,pol,cleanpol,ccleanpol -Alists what is left in a cluster, andsum(kyverno_deprecated_api_requests_total) by (kind)shows which legacy kinds are still being created or updated. Read the release notes of every minor version and upgrade with Helm, not by re-applyinginstall.yaml. - A fail-closed webhook can block the cluster — policies fail closed by default; give Kyverno its own namespace, run three admission replicas and keep that namespace excluded (the chart default). If the API server is stuck because Kyverno is down, delete
kyverno-resource-validating-webhook-cfgandkyverno-resource-mutating-webhook-cfg; Kyverno recreates them. - Test in CI —
kyverno applyandkyverno testneed no cluster; keep akyverno-test.yamlnext to each policy. - Exceptions are a bypass — restrict them to one namespace only platform admins can write to and give each an
expiresAt. - Not a runtime sensor — Kyverno acts at admission and in background scans; it does not watch running processes (use Falco or Tetragon), and it complements rather than replaces RBAC.