Secure a Kubernetes Cluster

Enforce Pod security policies and scan container images for CVEs locally.

Introduction

30 Second Summary

Every building has a security guard at the entrance who checks IDs and turns away anyone who shouldn't be there. Your Kubernetes cluster needs the same thing. Without it, any misconfigured or vulnerable workload can walk right in.

In this project, you will install an admission controller that blocks insecure deployments at the gate, write policies that enforce security best practices automatically, and scan container images for known vulnerabilities.

What You'll Build

A security-hardened local Kubernetes cluster that actively rejects insecure deployments using Kyverno admission policies and Trivy image scanning.

By the end of this project, you'll have:

  • A running kind cluster with Kyverno installed as an admission controller that intercepts every Pod creation request.
  • A set of ValidatingPolicies that block privileged containers, require non-root execution, and enforce resource limits on all workloads.
  • A MutatingPolicy that auto-injects security defaults into Pods so developers get secure-by-default behavior.
  • Secret Mission: Add an image registry allowlist policy that only permits images from verified sources.

Are there any prerequisites?

This project assumes familiarity with Kubernetes basics (kubectl, manifests, and Helm basics). You should also have Docker, Helm, kubectl, and Trivy already installed on your machine.

Set Up kind and Install Kyverno

Before you can enforce security policies on a Kubernetes cluster, you need a cluster to work with. In this step, you will spin up a local cluster using kind and install Kyverno as its admission controller.

kind runs Kubernetes inside Docker containers, giving you a full cluster without cloud costs or VM overhead. Kyverno then hooks into the cluster's API server as a webhook, intercepting every Pod creation request before it runs.

In this step, get ready to:
  • Install kind and create a local Kubernetes cluster.
  • Install Kyverno via Helm as the admission controller.
  • Verify that Kyverno webhooks are intercepting admission requests.
Install kind and create a local cluster

kind (Kubernetes IN Docker) creates lightweight clusters by running each Kubernetes node as a Docker container. You will install kind v0.32.0 and use it to spin up a single-node cluster named security-lab.

  • Check whether kind is already installed by running this command:
kind version

✔️ I see version 0.32.0 or higher

You already have the correct version of kind installed. Skip ahead to creating the cluster below.

ⓧ I see an older version

You have an outdated version of kind that may not support all features used in this project. Remove the old binary and install the correct version.

  • Download and install kind v0.32.0 by running these commands:
[ $(uname -m) = x86_64 ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.32.0/kind-linux-amd64
[ $(uname -m) = aarch64 ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.32.0/kind-linux-arm64
chmod +x ./kind
sudo mv ./kind /usr/local/bin/kind
  • Confirm the version by running:
kind version

You should see v0.32.0 in the output.

ⓧ Command not found

kind is not installed yet. You will download the binary and move it into your PATH.

  • Install kind v0.32.0 by running these commands:
[ $(uname -m) = x86_64 ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.32.0/kind-linux-amd64
[ $(uname -m) = aarch64 ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.32.0/kind-linux-arm64
chmod +x ./kind
sudo mv ./kind /usr/local/bin/kind
  • Confirm the installation succeeded by running:
kind version

You should see v0.32.0 in the output.

What is kind?

kind stands for Kubernetes IN Docker. It creates a full Kubernetes cluster by running each node as a Docker container on your local machine.

This makes it extremely fast to spin up and tear down clusters for testing. No cloud account or VM required.

  • Create a single-node cluster named security-lab by running this command:
kind create cluster --name security-lab --wait 30s

What does this command do?

The --name security-lab flag gives your cluster a custom name so you can identify it later.

The --wait 30s flag tells kind to wait up to 30 seconds for the control plane to become ready before returning.

  • Verify the cluster is running by running these commands:
kubectl cluster-info
kubectl get nodes

You should see the Kubernetes control plane address and a single node in Ready state.

Cluster creation failed?

Make sure Docker is running. kind requires Docker to create container-based nodes.

If you see a timeout error, try running the create command again. Slow disk I/O on the first pull of the node image can cause this.

help me troubleshoot why my kind cluster failed to create

Install Kyverno via Helm

Kyverno is an admission controller that intercepts requests to the Kubernetes API server. You will install it using Helm, a package manager for Kubernetes.

  • Add the Kyverno Helm repository and install Kyverno by running these commands:
helm repo add kyverno https://kyverno.github.io/kyverno/
helm repo update
helm install kyverno kyverno/kyverno -n kyverno --create-namespace

What do these commands do?

  • helm repo add registers the Kyverno chart repository so Helm knows where to find the package.
  • helm repo update fetches the latest chart list from the repository.
  • helm install deploys Kyverno into a dedicated kyverno namespace, which the --create-namespace flag creates automatically.
  • Wait for all Kyverno pods to become ready by running:
kubectl -n kyverno wait --for=condition=Ready pod --all --timeout=120s

This command blocks until every pod in the kyverno namespace reports Ready, or times out after 120 seconds.

  • Confirm that Kyverno pods are running by running:
kubectl -n kyverno get pods

You should see multiple pods (including the admission controller and background controller) all showing Running status with 1/1 ready containers.

Pods stuck in Pending or CrashLoopBackOff?

Run kubectl -n kyverno describe pod <pod-name> to see the Events section at the bottom. Look for image pull errors or resource constraints.

If you see image pull issues, check your internet connection. Kyverno images are pulled from ghcr.io.

help me debug why my Kyverno pods are not starting

Verify Kyverno is intercepting admission requests

Kyverno works by registering webhooks with the Kubernetes API server. When a Pod creation request comes in, the API server forwards it to Kyverno for validation or mutation before admitting it.

  • Check that Kyverno's webhooks are registered by running these commands:
kubectl get validatingwebhookconfigurations | grep kyverno
kubectl get mutatingwebhookconfigurations | grep kyverno

You should see webhook configuration entries containing kyverno in both outputs.

What are admission webhooks?

Admission webhooks are HTTP callbacks that the Kubernetes API server calls before persisting an object. Validating webhooks can reject a request. Mutating webhooks can modify it.

Kyverno registers both types so it can block non-compliant workloads (validation) and inject security defaults (mutation).

Why Kyverno over OPA Gatekeeper?

Kyverno uses native Kubernetes YAML and CEL expressions for policies. You do not need to learn Rego (a separate policy language required by OPA Gatekeeper).

Kyverno also includes built-in mutation and generation capabilities, making it a single tool for validate, mutate, and generate use cases.

No webhook configurations found?

Run kubectl -n kyverno get pods again. If pods are not all in Running state, the webhooks will not register.

Wait for the pods to fully start, then re-run the webhook check commands above.

help me figure out why Kyverno webhooks are not showing up

Your cluster is now running with Kyverno actively listening for admission requests. Next up, you will deploy intentionally insecure workloads to see what happens when no policies are in place.

Deploy Insecure Workloads

Your cluster is running and Kyverno is installed. But right now, there are no policies telling Kyverno what to block.

In this step, you will deploy intentionally insecure workloads to prove that a cluster without admission policies accepts anything. This establishes a baseline so you can see the difference once enforcement is active.

In this step, get ready to:
  • Deploy a privileged Pod that runs as root.
  • Deploy a Pod with no resource limits.
  • Observe that both insecure Pods run without any warnings or rejections.
Create a namespace and deploy a privileged Pod

A namespace in Kubernetes is an isolated boundary for resources. You will create one specifically for insecure test workloads.

  • Create the test namespace by running this command:
kubectl create ns insecure-apps

You should see namespace/insecure-apps created in your terminal output.

Now create the privileged Pod manifest. A privileged container has full access to the host system, which is a serious security risk.

  • Create a file called privileged-pod.yaml and add the following content:
apiVersion: v1
kind: Pod
metadata:
  name: privileged-app
  namespace: insecure-apps
spec:
  containers:
  - name: app
    image: nginx:1.27
    securityContext:
      privileged: true
      runAsUser: 0

What makes this Pod insecure?

Two fields under securityContext make this Pod dangerous:

  • privileged: true gives the container full access to the host kernel. It can modify network settings, access devices, and escape its isolation boundary.
  • runAsUser: 0 runs the container process as the root user, which means it has maximum permissions inside the container.
  • Save privileged-pod.yaml.
  • Deploy the privileged Pod by running:
kubectl apply -f privileged-pod.yaml

You should see pod/privileged-app created. The Pod was accepted without any pushback.

Seeing an error about the namespace?

If you see a message about the namespace not being found, make sure you ran kubectl create ns insecure-apps successfully before applying the manifest.

help me debug why my kubectl apply failed with a namespace not found error

✔️ Awesome, I've got everything!

Great. Make sure you have saved privileged-pod.yaml and confirmed the Pod was created.

ⓧ I'd like to double check the full code

apiVersion: v1
kind: Pod
metadata:
  name: privileged-app
  namespace: insecure-apps
spec:
  containers:
  - name: app
    image: nginx:1.27
    securityContext:
      privileged: true
      runAsUser: 0
Deploy a Pod with no resource limits

A Pod without resource limits can consume unlimited CPU and memory on the node. In production, this starves other workloads and can crash the entire node.

  • Create a file called no-limits-pod.yaml and add the following content:
apiVersion: v1
kind: Pod
metadata:
  name: no-limits-app
  namespace: insecure-apps
spec:
  containers:
  - name: app
    image: nginx:1.27

What is missing here?

This manifest has no resources.limits or resources.requests defined. Without limits, the container can use as much CPU and memory as the node has available.

Production clusters enforce limits so a single misbehaving workload cannot monopolize shared infrastructure.

  • Save no-limits-pod.yaml.
  • Deploy the Pod with no limits by running:
kubectl apply -f no-limits-pod.yaml

You should see pod/no-limits-app created. Again, no warnings or rejections.

Pod stuck in ContainerCreating?

If the Pod is not reaching Running status, it may be pulling the nginx:1.27 image. Wait 30 seconds and check again with kubectl -n insecure-apps get pods.

help me debug why my Pod is stuck in ContainerCreating status on a kind cluster

✔️ Awesome, I've got everything!

Great. Make sure you have saved no-limits-pod.yaml and confirmed the Pod was created.

ⓧ I'd like to double check the full code

apiVersion: v1
kind: Pod
metadata:
  name: no-limits-app
  namespace: insecure-apps
spec:
  containers:
  - name: app
    image: nginx:1.27
Observe the insecure state and clean up

Both Pods deployed without a single complaint from the cluster. Let's verify their state and inspect the security context to confirm how insecure they really are.

  • List the running Pods in the insecure-apps namespace by running:
kubectl -n insecure-apps get pods

You should see both privileged-app and no-limits-app with Running status.

  • Inspect the security context of the privileged Pod by running:
kubectl -n insecure-apps get pod privileged-app -o jsonpath='{.spec.containers[0].securityContext}' | python3 -m json.tool

What does this output show?

The JSON output confirms privileged: true and runAsUser: 0. These are the exact settings that production admission policies should reject. Right now, nothing stops them.

You have proven the point. Time to clean up these insecure workloads before adding enforcement.

  • Delete the insecure-apps namespace and all its Pods by running:
kubectl delete ns insecure-apps

You should see namespace "insecure-apps" deleted after a few seconds. This removes both Pods along with the namespace.

Namespace stuck in Terminating?

Namespace deletion can take 10-15 seconds while Kubernetes cleans up resources inside it. If it takes longer than a minute, press Ctrl+C and check if the namespace is gone with kubectl get ns.

help me debug a Kubernetes namespace stuck in Terminating state

You have established the baseline. Both insecure Pods ran without any checks. Next up, you will write validation policies that make Kyverno actively block these exact misconfigurations at admission time.

Enforce Security with Validation Policies

In the previous step, you proved the cluster has zero enforcement. Both a privileged container and a Pod with no resource limits deployed without any pushback.

Now you will write Kyverno ValidatingPolicies that block dangerous configurations at the API server level. These policies use CEL (Common Expression Language) expressions to inspect incoming Pod specs and reject anything that violates your security rules.

In this step, get ready to:
  • Write a ValidatingPolicy that blocks privileged containers and requires non-root execution.
  • Test the policy by deploying both compliant and non-compliant Pods.
  • Write a second ValidatingPolicy that requires CPU and memory limits on all containers.
Create a ValidatingPolicy that blocks privileged containers

The most dangerous Pod configuration is one that runs as root with full privileges. This gives the container the same level of access as the host node itself.

  • Create a file called block-privileged.yaml by running:
touch block-privileged.yaml
  • Open block-privileged.yaml in your editor and paste the following policy:
apiVersion: policies.kyverno.io/v1alpha1
kind: ValidatingPolicy
metadata:
  name: block-privileged
spec:
  validationActions:
    - Deny
  matchConstraints:
    resourceRules:
      - apiGroups: ['']
        apiVersions: ['v1']
        operations: ['CREATE', 'UPDATE']
        resources: ['pods']
  validations:
    - message: "Privileged containers are not allowed"
      expression: "!has(object.spec.containers) || object.spec.containers.all(c, !has(c.securityContext) || !has(c.securityContext.privileged) || c.securityContext.privileged == false)"
    - message: "Containers must not run as root (runAsNonRoot must be true)"
      expression: "!has(object.spec.containers) || object.spec.containers.all(c, has(c.securityContext) && has(c.securityContext.runAsNonRoot) && c.securityContext.runAsNonRoot == true)"

What does this policy do?

This ValidatingPolicy intercepts every Pod CREATE and UPDATE request at the API server.

  • The first validation rejects any container where privileged: true is set in the security context.
  • The second validation requires every container to explicitly set runAsNonRoot: true. Pods that omit this field or set it to false are denied.
  • The validationActions: [Deny] setting means non-compliant requests are blocked immediately rather than just audited.
  • Save block-privileged.yaml.
  • Apply the policy to your cluster by running:
kubectl apply -f block-privileged.yaml
  • You should see output confirming the ValidatingPolicy was created.

Policy not applying?

  • Make sure all Kyverno pods are still running with kubectl -n kyverno get pods. All pods should show Running status.
  • Check for YAML indentation errors. The validations field must be aligned under spec.

Help me troubleshoot why my Kyverno ValidatingPolicy is not being applied to my cluster.

Test the policy with compliant and non-compliant Pods

Your policy is live. Time to prove it works by attempting to deploy the same privileged Pod that ran freely in the previous step.

  • Create the secure-apps namespace by running:
kubectl create ns secure-apps
  • Attempt to deploy the privileged Pod into the new namespace by running:
kubectl apply -f privileged-pod.yaml --namespace=secure-apps

This time the request should be denied. You will see an error message from the admission webhook containing the text from your policy's message field.

What should I see?

The output will include an error indicating the request was denied by Kyverno. The denial message references your block-privileged policy and includes the message you defined: "Privileged containers are not allowed".

This confirms the admission controller is intercepting and blocking insecure configurations before the Pod ever starts.

Now deploy a Pod that meets all requirements. This proves the policy does not block legitimate workloads.

  • Create a file called secure-pod.yaml and paste the following:
apiVersion: v1
kind: Pod
metadata:
  name: secure-app
  namespace: secure-apps
spec:
  containers:
  - name: app
    image: nginx:1.27
    securityContext:
      runAsNonRoot: true
      runAsUser: 1000
      allowPrivilegeEscalation: false
    resources:
      requests:
        memory: "64Mi"
        cpu: "100m"
      limits:
        memory: "128Mi"
        cpu: "200m"

What makes this Pod compliant?

  • runAsNonRoot: true ensures the container does not run as root.
  • runAsUser: 1000 explicitly sets a non-root user ID.
  • allowPrivilegeEscalation: false prevents the process from gaining extra privileges at runtime.
  • Memory and CPU limits are defined, which the next policy will require.
  • Save secure-pod.yaml and deploy it by running:
kubectl apply -f secure-pod.yaml
  • You should see pod/secure-app created confirming Kyverno allowed the compliant Pod through.

Secure Pod also being denied?

  • Double check that runAsNonRoot: true is present under securityContext in your secure-pod.yaml. This field is required by the policy.
  • Make sure the namespace in the file is secure-apps (not insecure-apps).

Help me figure out why my secure Pod is being denied by Kyverno even though it has runAsNonRoot set to true.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is what block-privileged.yaml should look like:

apiVersion: policies.kyverno.io/v1alpha1
kind: ValidatingPolicy
metadata:
  name: block-privileged
spec:
  validationActions:
    - Deny
  matchConstraints:
    resourceRules:
      - apiGroups: ['']
        apiVersions: ['v1']
        operations: ['CREATE', 'UPDATE']
        resources: ['pods']
  validations:
    - message: "Privileged containers are not allowed"
      expression: "!has(object.spec.containers) || object.spec.containers.all(c, !has(c.securityContext) || !has(c.securityContext.privileged) || c.securityContext.privileged == false)"
    - message: "Containers must not run as root (runAsNonRoot must be true)"
      expression: "!has(object.spec.containers) || object.spec.containers.all(c, has(c.securityContext) && has(c.securityContext.runAsNonRoot) && c.securityContext.runAsNonRoot == true)"

Here is what secure-pod.yaml should look like:

apiVersion: v1
kind: Pod
metadata:
  name: secure-app
  namespace: secure-apps
spec:
  containers:
  - name: app
    image: nginx:1.27
    securityContext:
      runAsNonRoot: true
      runAsUser: 1000
      allowPrivilegeEscalation: false
    resources:
      requests:
        memory: "64Mi"
        cpu: "100m"
      limits:
        memory: "128Mi"
        cpu: "200m"
Create a ValidatingPolicy that requires resource limits

A container without resource limits can consume all available CPU and memory on a node, starving other workloads. This second policy enforces that every container declares both CPU and memory limits.

  • Create a file called require-limits.yaml and paste the following policy:
apiVersion: policies.kyverno.io/v1alpha1
kind: ValidatingPolicy
metadata:
  name: require-resource-limits
spec:
  validationActions:
    - Deny
  matchConstraints:
    resourceRules:
      - apiGroups: ['']
        apiVersions: ['v1']
        operations: ['CREATE', 'UPDATE']
        resources: ['pods']
  validations:
    - message: "All containers must have memory and CPU limits defined"
      expression: "object.spec.containers.all(c, has(c.resources) && has(c.resources.limits) && has(c.resources.limits.memory) && has(c.resources.limits.cpu))"

What does this policy do?

The CEL expression checks every container in the Pod spec. Each container must have a resources.limits block with both memory and cpu defined.

If any container is missing either limit, the entire Pod creation is denied. This prevents a single misconfigured container from destabilizing the whole node.

  • Save require-limits.yaml and apply it by running:
kubectl apply -f require-limits.yaml
  • Test this policy by attempting to deploy the no-limits Pod into secure-apps by running:
kubectl apply -f no-limits-pod.yaml --namespace=secure-apps

The request should be denied with a message about missing memory and CPU limits.

No-limits Pod not being denied?

  • Verify the policy was created by running kubectl get validatingpolicies. You should see require-resource-limits in the list.
  • Make sure you included the --namespace=secure-apps flag. Without it, the Pod deploys to the default namespace.

Help me figure out why my require-resource-limits ValidatingPolicy is not blocking Pods that have no limits defined.

✔️ Awesome, I've got everything!

Great. Double check you have saved require-limits.yaml before moving on.

ⓧ I'd like to double check the full code

Here is what require-limits.yaml should look like:

apiVersion: policies.kyverno.io/v1alpha1
kind: ValidatingPolicy
metadata:
  name: require-resource-limits
spec:
  validationActions:
    - Deny
  matchConstraints:
    resourceRules:
      - apiGroups: ['']
        apiVersions: ['v1']
        operations: ['CREATE', 'UPDATE']
        resources: ['pods']
  validations:
    - message: "All containers must have memory and CPU limits defined"
      expression: "object.spec.containers.all(c, has(c.resources) && has(c.resources.limits) && has(c.resources.limits.memory) && has(c.resources.limits.cpu))"

Your cluster now has two active security policies. Any Pod that runs as root, uses privileged mode, or skips resource limits is automatically rejected before it ever runs. Next up, you will scan container images with Trivy to find the vulnerabilities these policies protect against.

Scan Images with Trivy

Your cluster now actively blocks insecure Pods at admission time. But how do you decide which container images are safe to deploy in the first place?

In this step, you will use Trivy to scan container images for known vulnerabilities called CVEs. The scan results show you exactly which packages inside an image have security flaws, helping you make informed decisions about what belongs in your cluster.

In this step, get ready to:
  • Scan an end-of-life Python image and observe HIGH/CRITICAL CVEs.
  • Compare it against a current Python image to see the difference.
  • Scan the nginx image your cluster already uses.
Scan a known-vulnerable image for CVEs

Python 3.8 reached end-of-life in October 2024. Its base image no longer receives security patches, which makes it a reliable example of what a vulnerable image looks like in a scan.

  • Scan the python:3.8-slim image for HIGH and CRITICAL vulnerabilities by running this command:
trivy image --severity HIGH,CRITICAL python:3.8-slim

What does this command do?

The --severity HIGH,CRITICAL flag filters the output to only show actionable findings. Without it, Trivy reports LOW and MEDIUM issues too, which creates noise when you are making deployment decisions.

Trivy downloads the image layers and compares every installed package against its vulnerability database. Each match is a CVE with a severity rating.

The scan takes a moment while Trivy downloads the image and its vulnerability database. You should see a table listing multiple HIGH and CRITICAL CVEs, each with a package name, installed version, and fixed version.

What is a CVE?

A CVE (Common Vulnerabilities and Exposures) is a publicly disclosed security flaw with a unique identifier like CVE-2024-XXXXX. Each one describes a specific weakness that attackers could exploit.

CRITICAL means the vulnerability is remotely exploitable with little effort. HIGH means it is exploitable but may require specific conditions.

Scan taking too long or failing?

Trivy needs internet access to download its vulnerability database on first run. If the scan hangs, check your network connection.

If you see a timeout error, try running the command again. The database download can be slow on the first attempt but subsequent scans reuse the cached database.

Why is my Trivy scan failing or timing out?

Compare with a current, well-maintained image

Now you know what a vulnerable image looks like. The next question is: does upgrading to a maintained version actually fix the problem?

  • Scan the current python:3.12-slim image by running:
trivy image --severity HIGH,CRITICAL python:3.12-slim

You should see significantly fewer (or zero) CRITICAL vulnerabilities compared to the 3.8 image. This demonstrates why keeping base images current is a frontline security practice.

Why such a big difference?

Maintained images receive regular security patches for their system packages. End-of-life images stop receiving patches, so vulnerabilities accumulate over time with no fixes available.

This is exactly why teams combine Trivy scanning with admission policies. The scan identifies risky images, and the policies enforce that only safe images reach the cluster.

Scan the nginx image used in your cluster

Your cluster is already running nginx:1.27 inside the secure-app Pod. Scanning it tells you whether your own running workload has known vulnerabilities.

  • Scan the nginx image by running:
trivy image --severity HIGH,CRITICAL nginx:1.27

Review the results. Note which packages have vulnerabilities and whether fixed versions are available. Even official images can carry base-layer CVEs from their underlying OS packages.

What should I do about base image CVEs?

Not every CVE is exploitable in your context. A vulnerability in a library your application never calls is lower risk than one in a network-facing package.

In production, teams triage scan results by checking whether a fixed version exists and whether the vulnerable package is reachable from the application's code path.

You can now identify which images are safe to deploy and which carry known vulnerabilities. In the next step, you will build a mutation policy that automatically injects security defaults into Pods, adding another layer of defense.

Add Mutation Policies for Security Defaults

Your Trivy scans revealed exactly which images carry known vulnerabilities. Now you know what's safe to run and what's not.

Validation policies block bad configurations, but they put the burden on developers to remember every security field. A mutation policy flips that model. It auto-injects secure defaults at admission time, giving developers secure-by-default behavior without manual effort.

In this step, get ready to:
  • Create a MutatingPolicy that auto-injects security defaults.
  • Test the mutation on a new Pod deployment.
  • Review all active policies on the cluster.
Create a MutatingPolicy that auto-injects a security context

A MutatingPolicy intercepts resources at admission time and modifies them before they are persisted. Your policy will inject readOnlyRootFilesystem: true into any container that does not already have it set.

This prevents containers from writing to their root filesystem, making it harder for attackers to persist malicious files.

  • Create a file called inject-security-defaults.yaml in the same directory as your other policy files, and paste the following:
apiVersion: policies.kyverno.io/v1alpha1
kind: MutatingPolicy
metadata:
  name: inject-security-defaults
spec:
  matchConstraints:
    resourceRules:
      - apiGroups: ['']
        apiVersions: ['v1']
        operations: ['CREATE']
        resources: ['pods']
  matchConditions:
    - name: no-security-context
      expression: "object.spec.containers.exists(c, !has(c.securityContext) || !has(c.securityContext.readOnlyRootFilesystem))"
  mutations:
    - patchType: JSONPatch
      jsonPatch:
        expression: >
          object.spec.containers.map(c, has(c.securityContext) && has(c.securityContext.readOnlyRootFilesystem) ? [] : [
            JSONPatch{op: "add", path: "/spec/containers/" + string(object.spec.containers.indexOf(c)) + "/securityContext/readOnlyRootFilesystem", value: true}
          ]).flatten()

What does this policy do?

This MutatingPolicy has three key sections:

  • matchConstraints targets only Pod CREATE operations. It does not fire on updates to existing Pods.
  • matchConditions uses a CEL expression to check if any container is missing the readOnlyRootFilesystem field. If every container already has it set, the policy skips the Pod entirely.
  • mutations uses a JSONPatch expression to inject readOnlyRootFilesystem: true into each container that is missing it.
  • Apply the MutatingPolicy to your cluster by running:
kubectl apply -f inject-security-defaults.yaml

You should see mutatingpolicy.policies.kyverno.io/inject-security-defaults created in the output.

Seeing a validation error?

Check that the apiVersion is exactly policies.kyverno.io/v1alpha1 and the kind is MutatingPolicy (capital M, capital P).

Also confirm Kyverno is still running with kubectl -n kyverno get pods. All pods should be in Ready state.

help me troubleshoot my MutatingPolicy not applying

✔️ Awesome, I've got everything!

Great. Make sure you have saved inject-security-defaults.yaml before moving on.

ⓧ I'd like to double check the full code

apiVersion: policies.kyverno.io/v1alpha1
kind: MutatingPolicy
metadata:
  name: inject-security-defaults
spec:
  matchConstraints:
    resourceRules:
      - apiGroups: ['']
        apiVersions: ['v1']
        operations: ['CREATE']
        resources: ['pods']
  matchConditions:
    - name: no-security-context
      expression: "object.spec.containers.exists(c, !has(c.securityContext) || !has(c.securityContext.readOnlyRootFilesystem))"
  mutations:
    - patchType: JSONPatch
      jsonPatch:
        expression: >
          object.spec.containers.map(c, has(c.securityContext) && has(c.securityContext.readOnlyRootFilesystem) ? [] : [
            JSONPatch{op: "add", path: "/spec/containers/" + string(object.spec.containers.indexOf(c)) + "/securityContext/readOnlyRootFilesystem", value: true}
          ]).flatten()
Test the mutation by deploying a new Pod

To confirm the mutation is working, you will deploy a Pod that satisfies the validation policies but does not explicitly set readOnlyRootFilesystem. Kyverno should inject it automatically.

  • Create a file called test-mutation.yaml and paste the following:
apiVersion: v1
kind: Pod
metadata:
  name: mutation-test
  namespace: secure-apps
spec:
  containers:
  - name: app
    image: nginx:1.27
    securityContext:
      runAsNonRoot: true
      runAsUser: 1000
      allowPrivilegeEscalation: false
    resources:
      requests:
        memory: "64Mi"
        cpu: "100m"
      limits:
        memory: "128Mi"
        cpu: "200m"

What does this Pod include?

This Pod passes both ValidatingPolicies. It sets runAsNonRoot: true and includes resource limits. However, it does NOT include readOnlyRootFilesystem in its security context.

If the MutatingPolicy is working, Kyverno will inject readOnlyRootFilesystem: true before the Pod is stored.

  • Deploy the test Pod and inspect its security context by running these commands:
kubectl apply -f test-mutation.yaml
kubectl -n secure-apps get pod mutation-test -o jsonpath='{.spec.containers[0].securityContext}' | python3 -m json.tool

You should see readOnlyRootFilesystem: true in the JSON output, even though you never specified it in the manifest.

Why is this powerful?

Developers deploying to this cluster get secure-by-default behavior without needing to remember every security context field. The mutation policy acts as a safety net that fills in security gaps automatically.

Don't see readOnlyRootFilesystem in the output?

Make sure you applied inject-security-defaults.yaml before deploying the test Pod. The MutatingPolicy only fires on CREATE operations, so if the Pod already exists, delete it and re-apply.

  • Delete and redeploy by running:
kubectl delete -f test-mutation.yaml
kubectl apply -f test-mutation.yaml

help me debug why my Kyverno mutation is not injecting readOnlyRootFilesystem

✔️ Awesome, I've got everything!

Great. Make sure you have saved test-mutation.yaml before moving on.

ⓧ I'd like to double check the full code

apiVersion: v1
kind: Pod
metadata:
  name: mutation-test
  namespace: secure-apps
spec:
  containers:
  - name: app
    image: nginx:1.27
    securityContext:
      runAsNonRoot: true
      runAsUser: 1000
      allowPrivilegeEscalation: false
    resources:
      requests:
        memory: "64Mi"
        cpu: "100m"
      limits:
        memory: "128Mi"
        cpu: "200m"
Review all active policies

Your cluster now has three policies working together. Two validate incoming workloads, and one mutates them. Let's confirm all three are active.

  • List all active policies by running these commands:
kubectl get validatingpolicies
kubectl get mutatingpolicies

You should see block-privileged and require-resource-limits in the validating list, and inject-security-defaults in the mutating list.

How do these policies work together?

This is the defense-in-depth pattern. Mutation policies inject safe defaults first, then validation policies check the final result. If a container somehow bypasses the mutation, the validation layer still catches it and rejects the request.

Together, they enforce that every Pod on your cluster runs as non-root, has resource limits, and uses a read-only root filesystem.

Your cluster now enforces security at admission time with both validation and mutation. Next up, you will take on the Secret Mission and lock down which image registries your cluster trusts.

Secret mission

Restrict Image Registries

Your cluster blocks privileged containers and enforces resource limits. But what about the images themselves? In this secret mission, you'll write a supply chain security policy that only permits containers from trusted registries, locking the door on images from unknown sources.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using Docker containers, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • kind cluster security-lab (running as Docker containers).
  • Kyverno Helm release (pods running inside the cluster).
  • Local YAML files (privileged-pod.yaml, no-limits-pod.yaml, block-privileged.yaml, require-limits.yaml, secure-pod.yaml, inject-security-defaults.yaml, test-mutation.yaml, restrict-registries.yaml).
  • kind binary installed at /usr/local/bin/kind.

Keep everything running

No action needed. Choose this if you're still actively experimenting with Kyverno policies or want to deploy more workloads to test against your security rules.

  • The kind cluster runs as Docker containers and uses minimal system resources.
  • Your YAML files stay on disk for future reference or modification.
  • The kind binary remains installed at /usr/local/bin/kind for creating new clusters later.

Pause - I'll come back to this later

Stop the cluster's Docker containers to free up memory. Your YAML files and the kind binary stay on disk so you can recreate the cluster later.

  • Delete the kind cluster by running:
kind delete cluster --name security-lab

This removes the Docker containers but your local YAML files remain. When you want to resume, recreate the cluster and re-apply your policies.

Why delete instead of pausing Docker containers?

kind does not officially support stopping and restarting clusters. Manually stopping the Docker container can cause IP address changes and cluster instability on restart. Deleting and recreating is the reliable path.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes the cluster, local files, and the kind binary.

  • Delete the kind cluster (this also removes Kyverno and all policies running inside it):
kind delete cluster --name security-lab
  • Delete all local YAML files:
rm -f privileged-pod.yaml no-limits-pod.yaml block-privileged.yaml require-limits.yaml secure-pod.yaml inject-security-defaults.yaml test-mutation.yaml restrict-registries.yaml
  • Remove the kind binary:
sudo rm /usr/local/bin/kind
  • Verify the cluster is gone by running:
kind get clusters

You should see no clusters listed in the output.

That's a Wrap!

That's a Wrap!

Nice work! You just built a security enforcement layer on a local Kubernetes cluster that actively rejects insecure deployments at admission time.

You've learned how to:

  • Written ValidatingPolicies using CEL expressions to block privileged containers and require resource limits at the Kubernetes API server level.
  • Scanned container images with Trivy to identify HIGH and CRITICAL CVEs, comparing vulnerable end-of-life images against well-maintained alternatives.
  • Created a MutatingPolicy that auto-injects security defaults into Pods, implementing defense-in-depth without requiring developers to remember every security context field.
  • Secret Mission: Enforced an image registry allowlist policy to prevent untrusted container sources from ever running in your cluster.

Ready to quiz yourself?