Security contexts

Running non-root and dropping capabilities.

Intermediate10 min · lesson 49 of 65
In plain terms
A security context is the safety gear you make a container wear: no working as the boss (root), no climbing to higher access, and only the one tool it genuinely needs in its belt.

By default, a container runs as root. Not some sandboxed pretend-root, the real user id (uid) 0, the same number the host kernel checks when it decides who may touch what. Namespaces and the runtime's trimmed capability list keep that root well short of owning the node, so it is not yet the account that owns the machine. It is the account with the fewest steps left to get there, and if an attacker breaks into your app, that's who they become. A security context is how you take that power away up front, before anything goes wrong. You might have perfect code today. The point is that the security context keeps holding tomorrow, when the code isn't perfect anymore.

In a manifest it is a short block of fields, and the node reads it as a set of orders for that container. Run as this ordinary user id, not uid 0. Hold none of the Linux capabilities (the fine-grained slices of root power, like binding low-numbered network ports or loading kernel modules) unless you name one. Gain no new privileges once you have started. The container still does its job. It just can't do much of anything else. A Pod, Kubernetes' wrapper around one or more containers, lets you set a securityContext on the Pod, on each container, or both.

What securityContext actually controls

Two levels, and the more specific one wins. A field set on the Pod's securityContext applies to every container in the Pod. The same field set on an individual container overrides the Pod value for that container alone. So treat Pod-level as your default and container-level as the exception. A few fields exist at only one level. fsGroup, which controls the group that owns mounted volumes, is Pod-only. capabilities, readOnlyRootFilesystem, and allowPrivilegeEscalation are container-only, because they're about one container's process and its disk. The rest, including runAsUser and runAsNonRoot, you can set at either level, so put them on the Pod as a blanket rule and override a single container only when it truly needs something different.

The settings that carry real weight are short. runAsNonRoot: true is a gate that tells the kubelet (the Kubernetes agent running on every node) to refuse any container that would start as uid 0. It also refuses the ones it cannot check: an image whose Dockerfile ends in USER appuser, a name rather than a number, gives the kubelet nothing to compare against 0, so it fails the container instead of guessing. runAsUser: 10001 pins the exact user id the process runs as. allowPrivilegeEscalation: false flips on the kernel's no_new_privs bit, so a setuid program (one allowed to run with its file owner's privileges) can't quietly hand a child process more power than its parent had. readOnlyRootFilesystem: true mounts the container's root disk read-only, so an intruder can't drop a toolkit or a persistence backdoor onto it. capabilities.drop ["ALL"] strips every capability, and then you add back only the one or two a workload genuinely needs. seccompProfile: RuntimeDefault switches on the runtime's syscall filter (a syscall is a request a program makes to the kernel), which blocks the dangerous ones that almost nothing legitimate ever calls. One field runs the other way. privileged: true hands a container the whole capability set and near-total access to the host's devices, which is close to handing over the node itself, so almost nothing outside a node agent or a storage driver has any business asking for it.

Put together, these turn a container from 'can do anything on this machine' into 'can do exactly its job and nothing more.' That's the whole idea behind least privilege. You're not trying to predict every attack. You're shrinking the blast radius, so whatever a single break gets its hands on stays as small as possible. Here's a Deployment that runs a checkout service with the lot switched on.

hardened.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkout
spec:
replicas: 1
selector:
matchLabels: { app: checkout }
template:
metadata:
labels: { app: checkout }
spec:
securityContext: # pod-level: the default for every container
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001 # kubelet chowns mounted volumes to this group
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: registry.example.com/checkout:1.4.2
securityContext: # container-level overrides for this container
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp # app needs somewhere writable
volumes:
- name: tmp
emptyDir: {}
apply.sh
kubectl apply -f hardened.yaml
kubectl get pods -l app=checkout
apply-output.txt
deployment.apps/checkout created
NAME READY STATUS RESTARTS AGE
checkout-7c9f8b6d54-4v2mn 1/1 Running 0 12s

Notice the scheduler had nothing to do with any of this. It picked a node and moved on. The enforcement happens later, on the node, inside the kubelet. When the kubelet asks the container runtime (containerd, spoken to over the Container Runtime Interface, or CRI) to create the container, it passes these settings along as part of the container config. The runtime is what actually sets the uid, drops the capabilities, applies the seccomp filter, and marks the root filesystem read-only. runAsNonRoot is the odd one out, because the kubelet checks that one itself. It reads the user baked into the image, and if that user is root while you asked for non-root, it fails the container before it ever runs. So the gate holds even when your manifest sets no runAsUser at all: the kubelet still catches an image whose Dockerfile ends in USER 0 and refuses to start it.

Verifying it actually took hold

Don't trust the YAML you applied, check the process that's actually running. A field you fat-fingered under the wrong indentation gets silently ignored, and the manifest still looks right. Read the truth back from the live container instead. Three commands and about ten seconds settle it.

verify.sh
kubectl exec deploy/checkout -- id
kubectl exec deploy/checkout -- sh -c 'grep -E "NoNewPrivs|Seccomp|CapEff|CapBnd" /proc/1/status'
kubectl exec deploy/checkout -- touch /oops
verify-output.txt
uid=10001 gid=10001 groups=10001
CapEff: 0000000000000000
CapBnd: 0000000000000000
NoNewPrivs: 1
Seccomp: 2
touch: /oops: Read-only file system
command terminated with exit code 1

Five things confirmed in one shot. The process runs as 10001, not root. CapBnd: 0000000000000000 is the line that proves the drop landed, because the bounding set is the ceiling on what this process could ever hold and it is empty, where a container without the drop would show the runtime's default set instead, something like 00000000a80425fb. Read CapEff on its own and you have proved nothing here: a non-root process starts with an empty effective set whether you dropped capabilities or not, so that line reads all zeros either way. NoNewPrivs: 1 means privilege escalation is off. Seccomp: 2 means the process is running in filter mode, so the RuntimeDefault profile is live (a 0 there would mean no filter at all). And the write to the root filesystem was refused outright, exactly as intended. If you want the declared spec instead of the runtime truth, kubectl get pod -l app=checkout -o jsonpath='{.items[0].spec.containers[0].securityContext}' prints it back as JSON.

read-only root breaks apps that look fine at boot
readOnlyRootFilesystem is the setting that bites you hours after deploy, not at startup. The Pod goes Running, health checks pass, everyone moves on. Then the app tries to write a cache file, a PID file, a session, or a temp upload, hits a read-only disk, and either crashes into CrashLoopBackOff or throws errors that read like a bug in the app, not a filesystem policy. The logs rarely say 'read-only root filesystem' plainly. Before you turn it on, find every path the process writes (usually /tmp, /var/run, sometimes a framework cache dir) and mount a small emptyDir volume at each one. That's why the manifest above mounts an emptyDir at /tmp. The read-only root still holds everywhere else.
why the hardened pod won't stay up
Hardened Pod fails to start or crashes
kubectl describe pod / kubectl logs
CreateContainerConfigError, event says 'container has runAsNonRoot and image will run as root'
Image's baked-in USER is root
Set runAsUser to a non-zero uid, or rebuild the image with a non-root USER
CrashLoopBackOff, logs show 'Read-only file system' or can't write /tmp
readOnlyRootFilesystem with nowhere writable
Mount an emptyDir at each path the app writes
CrashLoop, 'permission denied' binding port 80
You dropped CAP_NET_BIND_SERVICE with drop ALL
Move the app to a port above 1024, since adding the capability back only helps if the image binary carries it as a file capability
App starts but can't read its mounted volume, files owned by root
Volume ownership doesn't match the non-root uid
Set fsGroup so the kubelet chowns the volume to that group

allowPrivilegeEscalation false closes a common escape ladder. Pair it with runAsNonRoot.

readOnlyRootFilesystem needs explicit writable mounts for temp dirs. Miss one and the failure is quiet: the Pod starts fine, and the write error surfaces later worded like an application bug.

Pod-level and container-level contexts combine. For runAsUser, the value on the container wins over the Pod default.

Try this

Deploy the hardened Deployment, read its capability ceiling out of the running process, then start a privileged pod beside it and read the same line. On a default cluster the API server admits both, because a security context hardens the container it is written on and polices nothing else. The difference shows up inside them: the hardened container's CapBnd is all zeros, the privileged one's is a long run of f's, and how long depends on how many capabilities your kernel defines. If your namespace carries a pod-security.kubernetes.io/enforce label of baseline or restricted, the second pod is rejected at apply time instead, and that block comes from Pod Security Standards, not from the security context. Delete the privileged pod as soon as you have looked at it.

privileged.yaml
apiVersion: v1
kind: Pod
metadata:
name: danger
spec:
containers:
- name: shell
image: busybox:1.36
command: ["sleep", "3600"]
securityContext:
privileged: true # hands back everything the hardened pod gave up
terminal
$ kubectl apply -f hardened.yaml
$ kubectl rollout status deploy/checkout
$ kubectl exec deploy/checkout -- sh -c 'grep -E "CapBnd|NoNewPrivs" /proc/1/status'
CapBnd: 0000000000000000
NoNewPrivs: 1
$ kubectl apply -f privileged.yaml
$ kubectl wait --for=condition=Ready pod/danger --timeout=60s
$ kubectl exec danger -- sh -c 'grep -E "CapBnd|NoNewPrivs" /proc/1/status'
CapBnd: 000001ffffffffff
NoNewPrivs: 0
$ kubectl delete pod danger

Takeaway

securityContext is where you stop running as root and drop Linux capabilities. Small YAML, large blast-radius reduction.

Quick check
01You set runAsNonRoot: true on a Deployment but leave runAsUser unset, and the image's Dockerfile ends with USER 0 (root). What happens when the Pod schedules?
Correct — runAsNonRoot is a gate checked by the kubelet at container-create time. It reads the image's user, sees root, and fails the container before it runs. Pin a runAsUser or bake a non-root USER into the image.
Incorrect — No. runAsNonRoot isn't a value that root can win by default, it's a hard refusal. With no non-root uid available, the container never starts.
Incorrect — No. Plain Kubernetes never invents a uid for you. That auto-assignment behavior belongs to OpenShift's Security Context Constraints, not upstream securityContext.
Incorrect — No. The API server admits it fine. The failure happens later, on the node, in the kubelet. API-level rejection of root Pods is the job of Pod Security Standards, a separate control.
02In a hardened Deployment you want fsGroup, runAsNonRoot, and runAsUser as blanket defaults for every container, but readOnlyRootFilesystem and capabilities.drop applied to just one container. Which statement about where these fields may be set is correct?
Incorrect — some fields are level-restricted. fsGroup is Pod-only, and capabilities and readOnlyRootFilesystem are container-only.
Incorrect — Reversed: fsGroup is Pod-only, and runAsUser can be set at either the Pod or container level.
Correct — fsGroup owns mounted volumes for the whole Pod, capabilities and the root filesystem are per-container, and the runAs* fields default at Pod level and override per container.
Incorrect — each container has its own root filesystem, so readOnlyRootFilesystem is a container-only field.
03Your hardened container sets capabilities.drop: ["ALL"] and runs a web server that binds port 80. The Pod lands in CrashLoopBackOff with 'permission denied' on the bind. What fix keeps the hardening intact?
Incorrect — that reopens a hardening hole and still grants no bind capability; the bind needs CAP_NET_BIND_SERVICE, not privilege escalation.
Correct — and the high port is the half that always works. Adding the capability back is the narrow fix in the manifest, but this container runs as uid 10001, and Kubernetes does not put an added capability in the ambient set, so the kernel strips it from the process at exec unless the binary itself carries cap_net_bind_service.
Incorrect — running as root to bind a port throws away the whole point of the hardening.
Incorrect — the read-only root filesystem has nothing to do with binding a network port.

Related