Lesson 55 of 66 · 18 min read · Updated

Pod Security — securityContext and Pod Security Admission

In the previous lesson you controlled what a Pod may do to the Kubernetes API. This lesson is about what a container may do to the node it runs on. By default a container in Kubernetes runs as root, keeps a set of Linux capabilities, may gain more privileges through setuid binaries, and has a writable filesystem. None of that is needed by a typical .NET API, and every part of it helps an attacker who finds a way into your process.

Two tools fix this. The securityContext is how a Pod asks for fewer privileges. Pod Security Admission is how a namespace refuses Pods that did not ask.

We will cover the following topics:

  1. The securityContext fields that matter: runAsNonRoot, runAsUser, allowPrivilegeEscalation, capabilities, seccompProfile, readOnlyRootFilesystem, and fsGroup
  2. Building .NET images that run as non-root, including chiseled images
  3. The three Pod Security Standards: privileged, baseline and restricted
  4. The three Pod Security Admission modes: enforce, audit and warn
  5. Previewing the effect of a policy with a server-side dry run
  6. Taking a real Deployment from rejected to fully compliant, one error at a time
  7. Debugging a hardened container that has no shell

Technical Requirements

  1. A Kubernetes cluster and kubectl. The outputs here come from Docker Desktop's Kubernetes with the kind provisioner (one control plane and three workers, Kubernetes v1.36). Pod Security Admission is built into the API server and enabled by default, so any recent cluster works.
  2. Docker and the .NET 10 SDK to build the sample images.
  3. Images you build locally are visible to Docker Desktop's Kubernetes. With a cluster created by the kind CLI, run kind load docker-image <image> after each build.

Core Concepts

What a Container Can Do by Default

Containers are processes on the node, isolated with Linux namespaces and cgroups. The isolation is real but thin. Without a securityContext:

DefaultRisk
The process runs as the image's user, which is root (UID 0) for most imagesA container escape or a writable host mount becomes root on the node
The process keeps about fourteen Linux capabilities, such as CHOWN, NET_RAW and SETUIDNET_RAW allows packet spoofing; SETUID and SETGID help privilege escalation
Privilege escalation through setuid binaries is allowedA child process can gain more rights than its parent
seccomp is off (Unconfined) unless the kubelet is configured otherwiseEvery system call, including rarely used and dangerous ones, is available
The root filesystem is writableAn attacker can drop tools, modify binaries or plant files

A .NET API needs none of this. It listens on an unprivileged port (8080 since .NET 8), reads its own files, and talks over the network.

The securityContext Fields

A securityContext exists at two levels. The Pod level applies to every container and to volumes. The container level applies to one container and overrides the Pod level where both define a field.

FieldLevelWhat it does
runAsNonRoot: truePod or containerThe kubelet refuses to start the container if it would run as UID 0
runAsUser / runAsGroupPod or containerOverrides the user and primary group from the image
fsGroupPodAdds this group to every container and makes supported volumes group-owned and group-writable
seccompProfile.type: RuntimeDefaultPod or containerApplies the container runtime's default seccomp filter, which blocks dangerous system calls
allowPrivilegeEscalation: falseContainerSets the Linux no_new_privs flag, so setuid binaries cannot raise privileges
capabilities.drop: ["ALL"]ContainerRemoves every Linux capability
readOnlyRootFilesystem: trueContainerMounts the image's filesystem read-only; only volumes are writable
privileged: trueContainerThe opposite of all of the above: full access to the node. Avoid.

How runAsNonRoot Checks the User

runAsNonRoot is checked by the kubelet just before the container starts, and it trips people up in two ways:

  1. If the image runs as root and you did not set runAsUser, the container fails with container has runAsNonRoot and image will run as root.
  2. If the image sets a user by name, for example USER app, the kubelet cannot know that name's UID without reading the image's /etc/passwd, so it refuses with image has non-numeric user (app), cannot verify user is non-root.

The fix for the second case is a numeric user, either in the image (USER 1654) or in the Pod (runAsUser: 1654).

Non-Root .NET Images

Microsoft's .NET images already contain a non-root user called app with UID 1654, and they expose that number in the APP_UID environment variable. Use the variable in your Dockerfile so the image carries a numeric user:

USER $APP_UID

The chiseled images (mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled) go further. They contain only what the .NET runtime needs: no shell, no package manager, and far fewer packages to patch. They are a natural fit for the restricted level, with one consequence you will meet in the example: you cannot kubectl exec a shell into them.

Pod Security Standards

The Kubernetes project defines three Pod Security Standards, each a fixed list of checks:

LevelIntended forWhat it blocks, in short
privilegedSystem components such as CNI plugins, storage drivers, log collectorsNothing
baselineOrdinary applications you do not fully controlKnown privilege escalations: privileged: true, host namespaces (hostNetwork, hostPID, hostIPC), hostPath volumes, host ports, extra capabilities beyond a safe set, unsafe sysctls
restrictedYour own applications, hardenedEverything baseline blocks, plus it requires runAsNonRoot: true, allowPrivilegeEscalation: false, capabilities.drop: ["ALL"], a seccomp profile of RuntimeDefault or Localhost, and only safe volume types

readOnlyRootFilesystem is not required by any level. It is still worth setting, and the example does.

Pod Security Admission

Pod Security Admission (PSA) is the built-in admission controller that applies these standards. It replaced PodSecurityPolicy, which was removed in Kubernetes 1.25. You configure it with labels on the namespace:

pod-security.kubernetes.io/<mode>: <level>
pod-security.kubernetes.io/<mode>-version: <version>

There are three modes, and you can use all three at once with different levels:

ModeEffect on a violating Pod
enforceThe Pod is rejected by the API server
warnThe Pod is accepted, and the client (kubectl) shows a warning
auditThe Pod is accepted, and an annotation is added to the API server's audit log entry

Two details are important:

  1. Enforce applies to Pods, warn and audit also apply to workload templates. If a Deployment violates an enforce policy, the Deployment is accepted; it is the ReplicaSet controller that fails to create Pods, and nobody sees a warning unless they look at events. This is why you set warn to the same level as enforce.
  2. The version label pins the rules. latest follows the API server's version, so an upgrade can tighten the rules underneath you. Pinning to, for example, v1.36 keeps the checks stable until you choose to move.

Labelling a namespace never touches running Pods. PSA only checks Pods when they are created or updated.

Hands-On: Kubernetes Commands

See the Pod Security labels of all namespaces

Namespaces without labels are effectively privileged.

kubectl get namespaces -L pod-security.kubernetes.io/enforce,pod-security.kubernetes.io/warn

Preview a level without applying it

A server-side dry run evaluates existing Pods against the new level and lists violations.

kubectl label --dry-run=server --overwrite namespace sec-payments pod-security.kubernetes.io/enforce=restricted

Enforce a level, pinned to a version

Pinning keeps the checks stable across cluster upgrades.

kubectl label --overwrite namespace sec-payments pod-security.kubernetes.io/enforce=restricted pod-security.kubernetes.io/enforce-version=v1.36

Find Pods the policy refused to create

The Deployment looks fine; the ReplicaSet records the rejection as events.

kubectl get events -n sec-payments --field-selector reason=FailedCreate

See the effective securityContext of a running Pod

Shows both the Pod-level and container-level settings.

kubectl get pod <pod-name> -n sec-payments -o jsonpath='{.spec.securityContext}{"\n"}{.spec.containers[0].securityContext}{"\n"}'

Check which user an image runs as

An empty value means root.

docker image inspect payments-api:2.0 --format '{{.Config.User}}'

Debug a container without a shell

Attach an ephemeral container that shares the target's process namespace. The restricted profile makes it pass the namespace's policy.

kubectl debug -it <pod-name> -n sec-payments --image=busybox:1.37 --target=payments-api --profile=restricted -- sh

Step-by-Step Example

The Scenario

The payments team runs PaymentsApi, a .NET 10 API that writes receipt files. The security team wants its namespace at the restricted level. You will deploy the API as it is today, introduce the policy without breaking anything, and then harden the Deployment until it is admitted, watching each error tell you what to fix next.

PaymentsApi has two endpoints. /whoami reads /proc/self/status to report the process's user, group, capabilities, no_new_privs flag and seccomp mode. POST /receipts/{id} writes a file, the way real applications write caches, uploads or temp files.

Step 1 — The Application

Create the project:

dotnet new web -n PaymentsApi
cd PaymentsApi

Replace Program.cs:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// /proc/self/status holds the real and effective user and group ids of the process.
static string StatusLine(string key) =>
string.Join(' ', File.ReadLines("/proc/self/status").First(l => l.StartsWith(key + ":"))
.Split(':', 2)[1].Split((char[])['\t', ' '], StringSplitOptions.RemoveEmptyEntries));

app.MapGet("/whoami", () => Results.Ok(new
{
uid = StatusLine("Uid"),
gid = StatusLine("Gid"),
groups = StatusLine("Groups"),
capabilities = StatusLine("CapEff"),
noNewPrivileges = StatusLine("NoNewPrivs"),
seccomp = StatusLine("Seccomp")
}));

app.MapPost("/receipts/{id}", (string id, IConfiguration config) =>
{
// Writes a file the way many apps write temp files, caches or uploads.
var folder = config["ReceiptFolder"] ?? "/app/receipts";
try
{
Directory.CreateDirectory(folder);
var path = Path.Combine(folder, $"{id}.txt");
File.WriteAllText(path, $"receipt {id} at {DateTime.UtcNow:O}");
return Results.Ok(new { written = path });
}
catch (Exception ex)
{
return Results.Problem($"{ex.GetType().Name}: {ex.Message}", statusCode: 500);
}
});

app.Run();

You will build three images from the same code. Version 1.0 is the typical Dockerfile, with no USER line, so it runs as root:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY PaymentsApi.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app --no-restore

FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "PaymentsApi.dll"]

Version 1.1 (Dockerfile.named) uses the chiseled base and the user name app:

FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
USER app
ENTRYPOINT ["dotnet", "PaymentsApi.dll"]

Version 2.0 (Dockerfile) is identical except for one line, which uses the numeric UID:

USER $APP_UID

Docker does not support comments at the end of a line, so keep the two variants in separate files rather than commenting one out inline. The build stage is the same in all three files.

Build all three, then check the configured users:

docker build -f Dockerfile.root -t payments-api:1.0 .
docker build -f Dockerfile.named -t payments-api:1.1 .
docker build -f Dockerfile -t payments-api:2.0 .
docker image inspect payments-api:1.0 payments-api:1.1 payments-api:2.0 --format '{{.RepoTags}} user={{.Config.User}}'
[payments-api:1.0] user=
[payments-api:1.1] user=app
[payments-api:2.0] user=1654

Step 2 — Deploy As It Is Today, with Warnings Only

Never start with enforce. Start with warn and audit, which change nothing but tell you what would break:

apiVersion: v1
kind: Namespace
metadata:
name: sec-payments
labels:
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/audit: restricted

The Deployment as the team runs it today:

apiVersion: apps/v1
kind: Deployment
metadata:
name: payments-api
namespace: sec-payments
spec:
replicas: 1
selector:
matchLabels:
app: payments-api
template:
metadata:
labels:
app: payments-api
spec:
containers:
- name: payments-api
image: payments-api:1.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
kubectl apply -f 1-namespace.yaml
kubectl apply -f 2-payments-insecure.yaml
Warning: would violate PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "payments-api" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "payments-api" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "payments-api" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "payments-api" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
deployment.apps/payments-api created

The Deployment was created and its Pod runs. The warning is the to-do list: four violations, each with the exact field to set. See what the process really has:

kubectl port-forward deploy/payments-api 18210:8080 -n sec-payments
curl -s localhost:18210/whoami
{
"uid": "0 0 0 0",
"gid": "0 0 0 0",
"groups": "0",
"capabilities": "00000000a80425fb",
"noNewPrivileges": "0",
"seccomp": "0"
}
curl -s -X POST localhost:18210/receipts/1001
{"written":"/app/receipts/1001.txt"}

UID 0 is root. 00000000a80425fb is the bitmask of the default capability set. noNewPrivileges 0 means setuid escalation works, and seccomp 0 means no filter. The application can write anywhere in its image.

Step 3 — Preview, Then Enforce

Before enforcing, ask the API server what would happen. Compare the two levels:

kubectl label --dry-run=server --overwrite ns sec-payments pod-security.kubernetes.io/enforce=baseline
namespace/sec-payments labeled (server dry run)

kubectl label --dry-run=server --overwrite ns sec-payments pod-security.kubernetes.io/enforce=restricted
Warning: existing pods in namespace "sec-payments" violate the new PodSecurity enforce level "restricted:latest"
Warning: payments-api-595b9579f8-tfjfh: allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true, seccompProfile
namespace/sec-payments labeled (server dry run)

The application already meets baseline, because it uses nothing privileged. It fails restricted. Enforce restricted anyway, pinned to the cluster's version, to see what the team will experience:

kubectl label --overwrite ns sec-payments pod-security.kubernetes.io/enforce=restricted pod-security.kubernetes.io/enforce-version=v1.36
Warning: existing pods in namespace "sec-payments" violate the new PodSecurity enforce level "restricted:v1.36"
Warning: payments-api-595b9579f8-tfjfh: allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true, seccompProfile
namespace/sec-payments labeled

The existing Pod keeps running; labels never evict anything. The trouble starts at the next rollout:

kubectl rollout restart deploy/payments-api -n sec-payments
kubectl get deploy,rs,pods -n sec-payments
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/payments-api 1/1 0 1 32s

NAME DESIRED CURRENT READY AGE
replicaset.apps/payments-api-595b9579f8 1 1 1 15s
replicaset.apps/payments-api-65748f4f75 1 0 0 5s

NAME READY STATUS RESTARTS AGE
pod/payments-api-595b9579f8-tfjfh 1/1 Running 0 15s

The new ReplicaSet wants one Pod and has zero. The reason is only in the events:

kubectl get events -n sec-payments --field-selector reason=FailedCreate
FailedCreate Error creating: pods "payments-api-65748f4f75-8hq25" is forbidden: violates PodSecurity "restricted:v1.36": allowPrivilegeEscalation != false (...), unrestricted capabilities (...), runAsNonRoot != true (...), seccompProfile (...)

This is the silent failure mode of enforce. The rollout is stuck, the old Pod keeps serving, and kubectl apply printed nothing alarming. In a real migration, keep warn and audit running long enough to fix every workload before you add enforce.

Step 4 — Add the securityContext

Add the four settings the warning asked for. runAsNonRoot and seccompProfile go at the Pod level, so they also cover any container added later. The other two are container-only fields:

spec:
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: payments-api
image: payments-api:1.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
kubectl apply -f 3-payments-secured-root-image.yaml
kubectl get pods -n sec-payments
NAME READY STATUS RESTARTS AGE
payments-api-595b9579f8-tfjfh 1/1 Running 0 35s
payments-api-7447c79598-kzdtt 0/1 CreateContainerConfigError 0 12s

Progress: PSA admitted the Pod, because the spec is now compliant. The kubelet then refused to start it:

kubectl describe pod payments-api-7447c79598-kzdtt -n sec-payments
Warning Failed ... Error: container has runAsNonRoot and image will run as root (...)

The manifest promised non-root, but the image still runs as root. The kubelet checks the promise.

Step 5 — Fix the Image User

Try the chiseled image with USER app:

kubectl set image deploy/payments-api payments-api=payments-api:1.1 -n sec-payments
kubectl describe pod <new-pod> -n sec-payments
Warning Failed ... Error: container has runAsNonRoot and image has non-numeric user (app), cannot verify user is non-root (...)

The second trap from Core Concepts: a user name cannot be verified. Use version 2.0, built with USER $APP_UID:

kubectl set image deploy/payments-api payments-api=payments-api:2.0 -n sec-payments
kubectl rollout status deploy/payments-api -n sec-payments
deployment "payments-api" successfully rolled out

Check the process again:

curl -s localhost:18211/whoami
{
"uid": "1654 1654 1654 1654",
"gid": "1654 1654 1654 1654",
"groups": "1654",
"capabilities": "0000000000000000",
"noNewPrivileges": "1",
"seccomp": "2"
}

Every line changed. The process runs as UID 1654, has no capabilities at all, cannot gain privileges, and runs under seccomp filter mode (2). The application did not notice: it still serves requests on port 8080.

If you cannot rebuild an image, setting runAsUser: 1654 in the Pod has the same effect as a numeric USER line.

Step 6 — Make the Root Filesystem Read-Only

Restricted does not require it, but a read-only root filesystem stops an attacker from writing tools or changing your binaries. Add one line to the container's securityContext:

securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
kubectl apply -f 4-payments-readonly.yaml
kubectl logs deploy/payments-api -n sec-payments
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://[::]:8080
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.

ASP.NET Core starts happily on a read-only filesystem. The receipt endpoint does not:

curl -s -X POST localhost:18212/receipts/1002
{"...","status":500,"detail":"IOException: Read-only file system : '/app/receipts'"}

This is the work readOnlyRootFilesystem creates: find every place the application writes, and give each one a volume.

Step 7 — Give the Application Writable Volumes, and Use fsGroup

The final Deployment mounts an emptyDir for receipts and another, memory-backed, for /tmp, where .NET and many libraries put temporary files. It also sets the user explicitly, adds fsGroup, and turns off the ServiceAccount token, since PaymentsApi never calls the Kubernetes API:

apiVersion: apps/v1
kind: Deployment
metadata:
name: payments-api
namespace: sec-payments
spec:
replicas: 1
selector:
matchLabels:
app: payments-api
template:
metadata:
labels:
app: payments-api
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 1654
runAsGroup: 1654
fsGroup: 1654
seccompProfile:
type: RuntimeDefault
containers:
- name: payments-api
image: payments-api:2.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
env:
- name: ReceiptFolder
value: /data/receipts
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
- name: receipts
mountPath: /data/receipts
volumes:
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 64Mi
- name: receipts
emptyDir:
sizeLimit: 256Mi
kubectl apply -f 5-payments-restricted.yaml
kubectl rollout status deploy/payments-api -n sec-payments
curl -s -X POST localhost:18213/receipts/1003
{"written":"/data/receipts/1003.txt"}

The sizeLimit values matter. A memory-backed emptyDir counts against the container's memory, and an unbounded one lets a bug fill the node's disk. In production, the receipts would go to a PersistentVolumeClaim or object storage, as covered in the storage lessons; the securityContext stays the same.

Step 8 — Look Inside a Container That Has No Shell

The chiseled image has no shell, so kubectl exec has nothing to run:

kubectl exec <pod> -n sec-payments -- sh -c 'echo hi'
... exec: "sh": executable file not found in $PATH

Attach an ephemeral debug container instead. Without a profile, kubectl debug adds the SYS_PTRACE capability, and the restricted namespace rejects it, which shows that PSA covers ephemeral containers too:

kubectl debug <pod> -n sec-payments --image=busybox:1.37 --target=payments-api -- sh
Error from server (Forbidden): pods "payments-api-744db4d7b7-b8rhn" is forbidden: violates PodSecurity "restricted:v1.36": allowPrivilegeEscalation != false (container "debugger-qg9qt" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "debugger-qg9qt" must set securityContext.capabilities.drop=["ALL"]; container "debugger-qg9qt" must not include "SYS_PTRACE" in securityContext.capabilities.add)

With --profile=restricted the debug container is compliant and runs as the same user as the application. It can see the application's process and, through /proc/1/root, its filesystem:

kubectl debug -it <pod> -n sec-payments --image=busybox:1.37 --target=payments-api --profile=restricted -- sh
/ $ id
uid=1654 gid=1654 groups=1654
/ $ ps
PID USER TIME COMMAND
1 1654 0:00 dotnet PaymentsApi.dll
/ $ ls -ldn /proc/1/root/data/receipts /proc/1/root/tmp
drwxrwsrwx 2 0 1654 4096 Oct 21 08:30 /proc/1/root/data/receipts
drwxrwsrwt 2 0 1654 100 Oct 21 08:30 /proc/1/root/tmp
/ $ ls -ln /proc/1/root/data/receipts
-rw-r--r-- 1 1654 1654 44 Oct 21 08:30 1003.txt
/ $ touch /proc/1/root/app/x
touch: /proc/1/root/app/x: Read-only file system

This listing shows fsGroup at work. Both volumes belong to group 1654 and carry the setgid bit (the s in rws), so every file created in them inherits group 1654. That is what lets a non-root process, or several containers running as different users in the same Pod, share a volume. The read-only root filesystem holds as well, even for the debug container.

Step 9 — Baseline for Workloads You Do Not Control

Restricted is for your own applications. For third-party software you cannot rebuild, baseline is the realistic floor: it allows root but blocks access to the node. Create a second namespace at baseline and try something that genuinely needs the node:

kubectl create namespace sec-legacy
kubectl label namespace sec-legacy pod-security.kubernetes.io/enforce=baseline
apiVersion: v1
kind: Pod
metadata:
name: node-inspector
namespace: sec-legacy
spec:
hostNetwork: true
containers:
- name: inspector
image: busybox:1.37
command: ["sleep", "3600"]
securityContext:
privileged: true
volumeMounts:
- name: host-root
mountPath: /host
volumes:
- name: host-root
hostPath:
path: /
kubectl apply -f 6-privileged-pod.yaml
Error from server (Forbidden): error when creating "6-privileged-pod.yaml": pods "node-inspector" is forbidden: violates PodSecurity "baseline:latest": host namespaces (hostNetwork=true), hostPath volumes (volume "host-root"), privileged (container "inspector" must not set securityContext.privileged=true)

kubectl run plain-root --image=payments-api:1.0 -n sec-legacy
pod/plain-root created

A Pod that mounts the node's whole filesystem as a privileged container is rejected; the root-running PaymentsApi 1.0 is accepted. Workloads that really need host access, such as CNI plugins or log collectors, belong in a dedicated namespace labelled privileged that only administrators can deploy to.

Step 10 — Clean Up

kubectl delete namespace sec-payments sec-legacy

Summary

  1. By default a container runs as root, keeps Linux capabilities, may escalate privileges, has no seccomp filter and a writable filesystem. A .NET API needs none of it.
  2. The securityContext removes them: runAsNonRoot, allowPrivilegeEscalation: false, capabilities.drop: ["ALL"], seccompProfile: RuntimeDefault, and optionally readOnlyRootFilesystem with emptyDir volumes for the paths you write to.
  3. Build .NET images with USER $APP_UID (numeric 1654). A user name such as USER app fails runAsNonRoot. Chiseled images make the smallest, shell-less non-root images.
  4. fsGroup makes volumes group-owned and setgid, so non-root processes can share them.
  5. Pod Security Admission applies the privileged, baseline and restricted standards per namespace, through enforce, warn and audit labels. Pin versions with -version labels.
  6. Roll out with warn and audit first and preview with --dry-run=server. Enforce rejects Pods, not Deployments, so a violating rollout fails silently and only events tell you why.
  7. Use kubectl debug --profile=restricted to inspect hardened, shell-less containers.

In the next lesson you will move from the node to the network and decide which Pods may talk to each other, with NetworkPolicies.