Security contexts
Running non-root and dropping capabilities.
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.
apiVersion: apps/v1kind: Deploymentmetadata:name: checkoutspec:replicas: 1selector:matchLabels: { app: checkout }template:metadata:labels: { app: checkout }spec:securityContext: # pod-level: the default for every containerrunAsNonRoot: truerunAsUser: 10001runAsGroup: 10001fsGroup: 10001 # kubelet chowns mounted volumes to this groupseccompProfile:type: RuntimeDefaultcontainers:- name: appimage: registry.example.com/checkout:1.4.2securityContext: # container-level overrides for this containerallowPrivilegeEscalation: falsereadOnlyRootFilesystem: truecapabilities:drop: ["ALL"]volumeMounts:- name: tmpmountPath: /tmp # app needs somewhere writablevolumes:- name: tmpemptyDir: {}
kubectl apply -f hardened.yamlkubectl get pods -l app=checkout
deployment.apps/checkout createdNAME READY STATUS RESTARTS AGEcheckout-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.
kubectl exec deploy/checkout -- idkubectl exec deploy/checkout -- sh -c 'grep -E "NoNewPrivs|Seccomp|CapEff|CapBnd" /proc/1/status'kubectl exec deploy/checkout -- touch /oops
uid=10001 gid=10001 groups=10001CapEff: 0000000000000000CapBnd: 0000000000000000NoNewPrivs: 1Seccomp: 2touch: /oops: Read-only file systemcommand 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.
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.
apiVersion: v1kind: Podmetadata:name: dangerspec:containers:- name: shellimage: busybox:1.36command: ["sleep", "3600"]securityContext:privileged: true # hands back everything the hardened pod gave up
$ kubectl apply -f hardened.yaml$ kubectl rollout status deploy/checkout$ kubectl exec deploy/checkout -- sh -c 'grep -E "CapBnd|NoNewPrivs" /proc/1/status'CapBnd: 0000000000000000NoNewPrivs: 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: 000001ffffffffffNoNewPrivs: 0$ kubectl delete pod danger
Takeaway
securityContext is where you stop running as root and drop Linux capabilities. Small YAML, large blast-radius reduction.