Sign in
← Kubernetes: security
8 min · medium · Lesson 1 of 3

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:

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:

FieldWhat it blocksWhat you'll see if the app needs it
runAsNonRoot: truestarting as UID 0Pod in CreateContainerConfigError
capabilities.drop: ["ALL"]kernel privileges from the default setOperation not permitted
allowPrivilegeEscalation: falseprivilege escalation through setuid filessudo, su don't raise privileges
readOnlyRootFilesystem: truewrites to the image's filesystemRead-only file system
seccompProfile: RuntimeDefaultrare, dangerous system callsusually 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

What you'll need for the tasks

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

Done reading?

Mark the lesson as read to move your track progress forward.

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.

Get access for $49

Tell the agent: use MCP izi, give me lesson k8s-security-01-security-context