Security contexts
runAsNonRoot, drop capabilities, no privilege escalation.
Run id inside a typical container and you'll probably see uid=0(root). That is not a cosmetic label. Unless your cluster remaps user namespaces, user ID 0 inside the container is the same UID 0 the host kernel obeys. One kernel bug, or one careless hostPath mount, and root in the pod becomes root on the node, and root over every other pod scheduled there. The securityContext block is where you take that power away before anyone gets the chance to use it.
A securityContext is the set of house rules you hand the kubelet (the Kubernetes agent on each node that actually starts containers) before it lets your container in the door: which user it runs as, whether it can pick up new privileges while running, which Linux capabilities it holds, and whether it can write to its own root filesystem. This is the highest-value block on a pod spec, because it decides how far a compromised process can travel before it ever touches the network or the API server. Get it right and a remote code execution bug (RCE, where an attacker gets to run their own commands inside your app) leaves them stuck in an unprivileged process with nowhere to go. Get it wrong and the same bug is a foothold on the host.
Three settings carry most of the weight. Run as a non-root numeric UID (user ID). Forbid privilege escalation. Drop every Linux capability, then hand back only the ones the app can prove it needs. Capabilities are root's power split into roughly 40 separate keys on a keyring. CAP_NET_BIND_SERVICE is the key that opens ports below 1024. CAP_SYS_ADMIN opens nearly every door in the building, which is why people call it the new root. A stock container image arrives holding keys the app never turns. So you take the whole ring back, then hand over one key, by name, when a real feature depends on it.
spec:securityContext: # pod-level: applies to all containersrunAsNonRoot: truerunAsUser: 10001runAsGroup: 10001fsGroup: 10001seccompProfile: { type: RuntimeDefault }containers:- name: appimage: registry.internal/app:1.4.2securityContext: # container-level: overrides / addsallowPrivilegeEscalation: falsereadOnlyRootFilesystem: truecapabilities:drop: ["ALL"]add: ["NET_BIND_SERVICE"] # only if it truly binds a port < 1024
Pod-wide defaults, per-container exceptions
Some fields sit at pod level and cover every container in the pod: runAsUser, runAsGroup, runAsNonRoot, fsGroup, seccompProfile. Others are per container: capabilities, allowPrivilegeEscalation, readOnlyRootFilesystem, privileged. Where both exist, the container value beats the pod default. That split is useful. You set one strict baseline for the whole pod, then hand a single container one visible exception instead of loosening everything at once. runAsGroup earns its line here. Leave it out and your process keeps group 0, the root group, even while its UID is unprivileged, so files owned by the root group are still within reach. Because the exception lives on one container, a reviewer can see exactly who asked for more, and go ask why.
Check it before it ships
You should not have to read a manifest line by line to know whether it is hardened. Kubesec scores a pod spec against these exact fields and tells you what each one earned you and what is still missing. Wire it into CI (continuous integration, the pipeline that builds and tests every change) and fail the build below a threshold, so a weak securityContext never reaches a cluster at all. A scanner only reads text, though. It cannot tell you whether the image obeys what the file asked for, and that gap is where hardening quietly leaks away.
# static score before it ever reaches the cluster$ kubesec scan pod.yaml[{"object": "Pod/app.default","valid": true,"fileName": "pod.yaml","message": "Passed with a score of 6 points","score": 6,"scoring": {"passed": [{ "id": "CapDropAll", "selector": "containers[] .securityContext .capabilities .drop | index(\"ALL\")", "reason": "Reduces the attack surface by dropping all capabilities", "points": 1 },{ "id": "ReadOnlyRootFilesystem", "selector": "containers[] .securityContext .readOnlyRootFilesystem == true", "reason": "An immutable root filesystem stops attackers writing binaries to disk", "points": 1 },{ "id": "RunAsNonRoot", "selector": ".spec, .spec.containers[] | .securityContext .runAsNonRoot == true", "reason": "Force the image to run as a non-root user", "points": 1 },{ "id": "RunAsUser", "selector": ".spec, .spec.containers[] | .securityContext .runAsUser -gt 10000", "reason": "Run as a high UID to avoid collisions with host users", "points": 1 },{ "id": "RunAsGroup", "selector": ".spec, .spec.containers[] | .securityContext .runAsGroup -gt 10000", "reason": "Run as a high GID to avoid collisions with host groups", "points": 1 },{ "id": "SeccompAny", "selector": ".spec .securityContext .seccompProfile .type", "reason": "Seccomp profiles set minimum privilege and guard against unknown threats", "points": 1 }],"advise": [{ "id": "ApparmorAny", "selector": ".spec .securityContext .appArmorProfile .type", "reason": "Well defined AppArmor policies may provide greater protection from unknown threats", "points": 3 }]}}]
Ask the kernel, not the YAML
A clean scan means the YAML is right. It does not prove the running container matches, because an image can quietly override what you asked for. So apply the pod, then read what the process actually holds in /proc, the kernel's own live view of every running process. The kernel has no reason to flatter you. Look at the Seccomp: 2 line. Seccomp (secure computing mode) is a kernel feature that filters system calls, and a system call is how a program asks the kernel to do something real, like open a file or a raw network socket. The RuntimeDefault profile is an allow-list for those requests, the way you might set a phone to ring only for numbers already in your contacts. The 2 means that filter is loaded and turning away the couple of dozen calls an ordinary app never makes.
$ kubectl apply -f pod.yamlpod/app created$ kubectl exec app -- iduid=10001 gid=10001 groups=10001 # non-root, as declared$ kubectl exec app -- grep -E 'CapEff|NoNewPrivs|Seccomp' /proc/1/statusCapEff: 0000000000000400 # only NET_BIND_SERVICE remainsNoNewPrivs: 1 # allowPrivilegeEscalation: false took holdSeccomp: 2 # RuntimeDefault filter loaded (2 = filter mode)$ kubectl exec app -- touch /etc/probetouch: /etc/probe: Read-only file system # readOnlyRootFilesystem: truecommand terminated with exit code 1
Where it actually bites
runAsNonRoot is enforcement, not decoration. If an image is built to start as root, the kubelet refuses to run it under that setting and the pod never comes up. Here is the same spec pointed at a legacy image whose Dockerfile ends with USER 0. Watch where it breaks.
# same securityContext, but the image's Dockerfile ends with USER 0$ kubectl apply -f legacy-pod.yamlpod/legacy created$ kubectl get pod legacyNAME READY STATUS RESTARTS AGElegacy 0/1 CreateContainerConfigError 0 6s$ kubectl describe pod legacy | grep -A1 WarningWarning Failed kubelet Error: container has runAsNonRootand image will run as root
A short list of fields undoes all of this in a single line, so treat each one as an automatic review blocker: privileged: true, hostPID, hostIPC, hostNetwork, and hostPath mounts. privileged is the worst of them. It switches seccomp off, ignores your capability drops, and lifts device restrictions in one go, handing the container the host's devices. When a manifest asks for any of the five, that is the first question you raise in review, and almost never the thing you approve.
When a sidecar genuinely needs something the baseline forbids, keep the loosening as small as you can make it: one container, one field, and a comment naming the feature that depends on it. A logging sidecar that wants somewhere to write is a volume problem, not a reason to drop readOnlyRootFilesystem across the whole pod. Narrow exceptions get approved in minutes. Broad ones sit in the review queue, which is the right outcome.
readOnlyRootFilesystem breaks real applications that expect to scribble somewhere. Anything that unpacks a template, writes a lock file, or caches to /tmp falls over on the first request. Give it an emptyDir mounted at the paths the app actually writes to, marked noexec where your runtime allows it, so the app can write data but never a binary it can then run.
Expect your first drop ALL rollout to break something, and read the error before you widen anything. A process that cannot bind port 80 wants NET_BIND_SERVICE, or better, a port above 1024 and no capability at all. A failure to change file ownership points at CHOWN. Add back the one named capability the error implicates, then run it again. Reaching for privileged because a container refuses to start turns a five-minute lookup into an open door that outlives the debugging session.
Test the bad pod, not only the good one, and test it on every cluster you run: lab, staging, and production. A Gatekeeper constraint (Gatekeeper is the admission controller that rejects manifests breaking your rules) that exists only in production teaches developers the wrong defaults all week, then ambushes them on Friday afternoon. Trying to deploy a pod with privileged: true and watching it get refused is the only proof the control is switched on.
Keep the evidence beside the control. In the same pull request that adds the securityContext, paste the id output and the CapEff, NoNewPrivs and Seccomp lines you read from /proc/1/status on a real run, so the next person on call can tell pass from fail without guessing. Then read them again a month later, while something is broken at 2am, because a baseline that only holds up when you are calm and reading carefully will not survive an incident.
Try this
Deploy a pod that runs as a non-root user, cannot escalate, and cannot write to its root filesystem. Then prove both claims from inside the container instead of trusting the manifest.
$ kubectl -n payments apply -f - <<'EOF'apiVersion: v1kind: Podmetadata: { name: locked }spec:securityContext:runAsNonRoot: truerunAsUser: 10001seccompProfile: { type: RuntimeDefault }containers:- name: cimage: busybox:1.36command: ["sleep","3600"]securityContext:allowPrivilegeEscalation: falsereadOnlyRootFilesystem: truecapabilities: { drop: ["ALL"] }volumeMounts:- { name: tmp, mountPath: /tmp }volumes:- { name: tmp, emptyDir: {} }EOFpod/locked created$ kubectl -n payments exec locked -- iduid=10001 gid=0 groups=0$ kubectl -n payments exec locked -- sh -c 'echo x > /bin/busybox' || echo RO_OKRO_OK
Takeaway
A pod worth trusting is one where id returns a high UID, CapEff comes back nearly empty, and a write to /etc fails. Set the fields, read /proc to confirm the node agreed with you, and put an admission rule behind it so nobody has to remember.