securityContext: running a Pod without root
The securityContext fields that turn an ordinary container into a non-root process with trimmed permissions and a read-only root.
Why this matters
By default, a container in Kubernetes runs as whatever user is baked into the image. Most of the time that's root. The process keeps a set of kernel privileges, can write to any folder in the image, and its system calls aren't filtered at all. If a hole is found in the app, an attacker gets all of that along with it.
securityContext strips these privileges right in the Pod manifest. The
app usually doesn't need them, and this is one of the first changes
reviewers ask for on a manifest.
What securityContext is
securityContext is a block of process security settings for a container. It lives at two levels:
spec.securityContextapplies to all containers in the Pod.spec.containers[i].securityContextapplies to one specific container. If the same field is set at both levels, the container's value wins.
The set of fields differs between the levels. Pod-only: fsGroup,
supplementalGroups, sysctls. Container-only: capabilities,
allowPrivilegeEscalation, readOnlyRootFilesystem, privileged. At both
levels: runAsUser, runAsGroup, runAsNonRoot, seccompProfile.
kubectl itself has the full list of fields with descriptions:
kubectl explain pod.spec.securityContext
kubectl explain pod.spec.containers.securityContext
Summary of what's covered below:
| Field | What it blocks | What you'll see if the app needs it |
|---|---|---|
runAsNonRoot: true | starting as UID 0 | Pod in CreateContainerConfigError |
capabilities.drop: ["ALL"] | kernel privileges from the default set | Operation not permitted |
allowPrivilegeEscalation: false | privilege escalation through setuid files | sudo, su don't raise privileges |
readOnlyRootFilesystem: true | writes to the image's filesystem | Read-only file system |
seccompProfile: RuntimeDefault | rare, dangerous system calls | usually nothing, the filter is invisible |
Blocking root: runAsNonRoot and runAsUser
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
runAsNonRoot: true turns on a check at container startup. It's done by
kubelet, the Kubernetes agent on the node that starts containers. If the
process would run as UID 0, the container doesn't start, the Pod hangs in
CreateContainerConfigError, and kubectl describe pod shows:
Error: container has runAsNonRoot and image will run as root (pod: "...", container: app)
This guards against the case where someone rolled out an image whose user isn't set, or is set to root.
If the image specifies the user by name (USER app) rather than a number,
kubelet can't verify it isn't root, and it also won't start the container:
image has non-numeric user (app), cannot verify user is non-root. A
runAsUser with a number, or USER 10001 in the Dockerfile, fixes this.
runAsUser: 10001 sets the process's UID no matter what's in the image.
runAsGroup sets the primary group. This only works if the app doesn't
need root to start. The official nginx image, for example, runs the
master process as root and only the worker processes as the nginx user.
There's a separate build, nginxinc/nginx-unprivileged, for running
without root; it listens on port 8080.
A process running as runAsUser: 10001 can't write to files and folders in
the image that belong to root: a different UID has no write permission.
When the app needs to write somewhere, it gets a separate volume (more on
this below), and for volumes with shared files there's fsGroup: kubelet
sets the volume's group to fsGroup, and the process writes to it as a
member of that group.
Dropping capabilities
Linux capabilities split root's privileges into separate pieces.
CAP_CHOWN allows changing the owner of any file, CAP_NET_RAW gives raw
sockets, CAP_SYS_ADMIN gives almost everything and is sometimes called
"the new root".
containerd, the runtime that runs containers in most clusters, grants a
container 14 capabilities by default: CAP_CHOWN, CAP_DAC_OVERRIDE,
CAP_FSETID, CAP_FOWNER, CAP_MKNOD, CAP_NET_RAW, CAP_SETGID,
CAP_SETUID, CAP_SETFCAP, CAP_SETPCAP, CAP_NET_BIND_SERVICE,
CAP_SYS_CHROOT, CAP_KILL, CAP_AUDIT_WRITE. A typical web app doesn't
need a single one of them. Drop them all:
containers:
- name: app
securityContext:
capabilities:
drop:
- ALL
If one is actually needed, return it with add:
capabilities:
drop: ["ALL"]
add: ["NET_BIND_SERVICE"]
NET_BIND_SERVICE used to be added so a non-root process could listen on
port 80. On containerd 2.x (used, for example, in kind), every container
has net.ipv4.ip_unprivileged_port_start=0 set, and ports below 1024 can be
bound without the capability. ICMP sockets are likewise allowed without
CAP_NET_RAW, so ping may still work even after drop: ["ALL"]. On other
runtimes and older containerd versions, this isn't the case.
What's left for the process is visible in /proc/1/status: the CapBnd
line shows the bounding set in hex. The default set looks like
00000000a80425fb; after drop: ["ALL"] it's all zeros. For a non-root
process, CapEff is zero even without drop, but an empty bounding set
guarantees the privileges can't come back, even through files with
capabilities set on them.
allowPrivilegeEscalation: false
This field controls the kernel flag no_new_privs: the process can't gain
more privileges than its parent has. The main practical effect: setuid
files stop escalating privileges, so sudo or su inside the container
won't give you root. In /proc/1/status this shows as NoNewPrivs: 1.
containers:
- name: app
securityContext:
allowPrivilegeEscalation: false
If the field isn't set, it defaults to true. You can't set it to false
for a container with privileged: true or CAP_SYS_ADMIN.
readOnlyRootFilesystem
Mounts the image's filesystem as read-only. Writing to any folder in the
image (/etc, /usr, /var, / itself) fails with
Read-only file system. An attacker can't replace a binary or append to a
config file. On the other hand, an app that writes logs, cache, or PID
files into its own folders breaks right at startup.
The fix: mount an emptyDir volume wherever the app needs to write.
emptyDir is an empty folder that lives as long as the Pod does.
containers:
- name: app
securityContext:
readOnlyRootFilesystem: true
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
/tmp is needed almost every time: many libraries write temporary files
there. Other folders depend on the app. For nginx, that's
/var/cache/nginx and /var/run, for example. Your app's logs will tell
you where it writes: a Read-only file system message usually includes the
path.
seccompProfile
seccomp filters a process's system calls.
spec:
securityContext:
seccompProfile:
type: RuntimeDefault
RuntimeDefault is the profile shipped by the runtime. Docker's profile,
which containerd's profile is also derived from, blocks about 44 system
calls out of 300-plus according to the documentation, for example
add_key, keyctl, and bpf. Ordinary apps don't use them and run
unchanged.
Other values: Unconfined (no filter) and Localhost (a custom profile
from a file on the node).
If the field isn't set, the container runs as Unconfined. You can enable
RuntimeDefault for a whole node with the kubelet option seccompDefault
(GA since Kubernetes 1.27), but it's turned on manually and is off by
default. That's why the field goes in the manifest instead of relying on
the cluster. In /proc/1/status, the filter shows as Seccomp: 2; without
a filter it's 0.
Full template
apiVersion: v1
kind: Pod
metadata:
name: secured
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: busybox:1.36.1
command: ["sh", "-c", "sleep 3600"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
This Pod passes the restricted level of the Pod Security Standards. These
are rules built into Kubernetes: a namespace labeled
pod-security.kubernetes.io/enforce=restricted won't accept a Pod that's
missing runAsNonRoot: true, allowPrivilegeEscalation: false,
drop: ["ALL"], or a seccomp profile of RuntimeDefault or Localhost.
The rejection message lists exactly what's missing:
Error from server (Forbidden): pods "probe" is forbidden: violates PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "probe" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "probe" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "probe" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "probe" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
restricted doesn't require readOnlyRootFilesystem; that's added on top,
by choice. Of the capabilities, restricted only allows adding back
NET_BIND_SERVICE.
Common mistakes
- The Pod hangs in
CreateContainerConfigErrorwithrunAsNonRoot. The image runs as root, or its user is set by name. You needrunAsUserwith a non-zero UID, or a different image. - You set
readOnlyRootFilesystem, and the container goes intoCrashLoopBackOff. The app is writing into the image's filesystem. Find the path inkubectl logs(the--previousflag shows the log of the crashed run) or in the image's documentation, and mount an emptyDir there. There's no need to turn offreadOnlyRootFilesystemfor this. - You set
runAsUser, and the app getsPermission deniedon its own folder. The folder in the image belongs to root. You need a volume, or an image built for that user. capabilitiesorallowPrivilegeEscalationwere written under the Pod'sspec.securityContext. These are container fields;kubectl applywill answer with an error about an unknown field.- You set
drop: [ALL], then added half the list back withadd. Only return what the app genuinely can't run without.
What you'll need for the tasks
- Lab 1 (
labs/01-report-crash):kubectl get pods,kubectl logs(with--previous),kubectl describe pod, anemptyDirvolume andvolumeMounts, editing a Deployment withkubectl editorkubectl apply. - Lab 2 (
labs/02-harden-pod): the fieldsrunAsNonRoot,runAsUser,seccompProfile,allowPrivilegeEscalation,capabilities.drop,readOnlyRootFilesystem, reading a Pod Security rejection message,kubectl exec, and/proc/1/status.
Homework
Practice the theory on your own cluster: 1 step with a check. Set up the cluster with the Kubernetes locally track, the tasks live in the k8s-practice repository.
./run.sh start k8s-security-context # set up the starting state
./run.sh verify k8s-security-context # check the current step
In short
securityContextexists on both the Pod and the container; a container field overrides the same field on the Pod.runAsNonRoot: trueblocks a process from starting as UID 0;runAsUsersets a specific UID.capabilities.drop: ["ALL"]removes kernel privileges from the default set; anything actually needed comes back throughadd.allowPrivilegeEscalation: falseturns onno_new_privs, so setuid files no longer escalate privileges.readOnlyRootFilesystem: truerequires an emptyDir on any folder the app writes to.seccompProfile: RuntimeDefaultbelongs in the manifest: without it, a container usually runs asUnconfined.
Done reading?
Mark the lesson as read to move your track progress forward.
Lesson marked as done.
Go through it with an AI agent
The agent explains anything unclear in plain words, gives you 2 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.
Tell the agent: use MCP izi, give me lesson k8s-security-01-security-context
Progress saved in this browser
Clear your history or open this on your phone, and the marks will be gone. Leave your email and they'll move to your account. No password needed.
Done. moved to your account. You can see your progress on any device. My progress