Ch. 14 · Kubernetes

Kubernetes Security Contexts and Runtime Permissions

Harden a Pod with runAsNonRoot, dropped capabilities, seccomp and a read-only root filesystem, and decode the errors each setting can produce.

~6 min readadvancedupdated Oct 7, 2026

By default a container runs as whatever user its image declares, which for a lot of images is root, with a default set of Linux capabilities, a writable root filesystem and no seccomp filter beyond what the runtime applies. That’s not as dangerous as root on the host (namespaces and cgroups still apply), but every one of those defaults makes a container escape or a compromised app easier to exploit. A security context is where you change them. This guide hardens one Pod field by field, including the errors you’ll hit on the way.

Assume Kubernetes 1.30 or newer with containerd as the runtime. The YAML parses cleanly; error messages and /proc output are representative of what you’ll see rather than captured here.

Pod level versus container level

securityContext appears in two places. At spec.securityContext (Pod level) it sets defaults for every container and for volumes: runAsUser, runAsGroup, runAsNonRoot, fsGroup, fsGroupChangePolicy, supplementalGroups, seccompProfile, sysctls. At spec.containers[].securityContext (container level) you can override the user and group fields, and set things that only make sense per container: capabilities, privileged, allowPrivilegeEscalation, readOnlyRootFilesystem, procMount.

When both set the same field, the container’s value wins. That makes the usual pattern: identity and seccomp at the Pod level, capabilities and filesystem per container.

A hardened Pod, field by field

Here’s the target for a typical HTTP service:

apiVersion: v1
kind: Pod
metadata:
  name: invoice-api
  namespace: billing
spec:
  automountServiceAccountToken: false
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    runAsGroup: 10001
    fsGroup: 10001
    fsGroupChangePolicy: OnRootMismatch
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: api
      image: registry.example.com/billing/invoice-api:1.14.0
      ports:
        - containerPort: 8080
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop: ["ALL"]
      volumeMounts:
        - name: tmp
          mountPath: /tmp
        - name: data
          mountPath: /var/lib/invoice
  volumes:
    - name: tmp
      emptyDir:
        sizeLimit: 64Mi
    - name: data
      persistentVolumeClaim:
        claimName: invoice-data
yaml

runAsUser: 10001 and runAsGroup: 10001. The process runs with this UID and primary GID regardless of the image’s USER. A high, arbitrary UID is deliberate: it won’t match a real user on the node, so if the process escapes the container it owns nothing there.

runAsNonRoot: true. This doesn’t change the user. It’s a check: the kubelet refuses to start the container if it would run as UID 0. That gives you two distinct errors to recognise. If the image runs as root and you haven’t set runAsUser:

Error: container has runAsNonRoot and image will run as root (pod: "invoice-api_billing(...)", container: api)
Text

And if the image declares a user by name, such as USER app, with no runAsUser in the Pod:

Error: container has runAsNonRoot and image has non-numeric user (app), cannot verify user is non-root
Text

The kubelet can’t resolve a name without reading the image’s /etc/passwd, so it gives up. Either use a numeric USER 10001 in the Dockerfile or set runAsUser. Both show up as CreateContainerConfigError in kubectl get pods; that guide covers the general case.

fsGroup: 10001. Volumes that support ownership management (most block volumes, emptyDir) are made group-owned by 10001 and group-writable, and the process gets 10001 as a supplementary group. Without it, a fresh ext4 volume is owned by root and a non-root process gets permission denied writing to /var/lib/invoice. fsGroupChangePolicy: OnRootMismatch skips the recursive chown when the volume root already has the right ownership. On a volume with millions of files, the default (Always) can make every Pod start take minutes.

seccompProfile: RuntimeDefault. Applies the container runtime’s default seccomp filter, which blocks a few dozen rarely needed and historically dangerous syscalls (kexec_load, open_by_handle_at, bpf without privileges, and others). Kubernetes doesn’t apply it by default unless the kubelet’s seccompDefault option is on, so it’s worth stating.

allowPrivilegeEscalation: false. Sets the Linux no_new_privs flag, so a setuid binary in the image (like su or ping on older images) can’t raise the process’s privileges. It’s the setting that makes runAsNonRoot hold up even if the image contains setuid tools.

capabilities: drop: ["ALL"]. Root’s power in Linux is split into capabilities. Container runtimes grant a default set including CHOWN, DAC_OVERRIDE, SETUID, SETGID, NET_RAW and NET_BIND_SERVICE. A web service needs none of them. If yours must bind port 80 or 443 as non-root, add back exactly that one:

capabilities:
  drop: ["ALL"]
  add: ["NET_BIND_SERVICE"]
yaml

The simpler fix is listening on 8080 and letting the Service map port 80 to it.

readOnlyRootFilesystem: true. The image’s filesystem becomes read-only, so an attacker can’t drop a binary into /usr/local/bin or modify the app’s code. Most apps still need somewhere to write temporary files, which is why the example mounts an emptyDir at /tmp. When you turn this on and the app crashes with Read-only file system (EROFS) writing to some path like /var/cache/nginx or /home/app/.cache, mount an emptyDir there rather than turning the setting back off.

automountServiceAccountToken: false. Not a security context field, but it belongs in the same review. If the app never calls the Kubernetes API, don’t give it a token that can.

Confirming what the process actually got

Verify from inside the running container rather than trusting the manifest:

$ kubectl exec -n billing invoice-api -- id
uid=10001 gid=10001 groups=10001
$ kubectl exec -n billing invoice-api -- grep -E 'Cap(Prm|Eff)|NoNewPrivs|Seccomp:' /proc/1/status
CapPrm: 0000000000000000
CapEff: 0000000000000000
NoNewPrivs:     1
Seccomp:        2
$ kubectl exec -n billing invoice-api -- touch /usr/local/bin/x
touch: /usr/local/bin/x: Read-only file system
Terminal

All-zero capability masks mean no capabilities; Seccomp: 2 means a filter is active. (This assumes the image has id and grep; a distroless image won’t, and you’d use an ephemeral debug container with kubectl debug instead.)

privileged and the settings that undo everything

privileged: true gives the container all capabilities, access to every host device, and disables seccomp and AppArmor confinement. Combined with a hostPath mount of /, it’s root on the node. Very few workloads need it: CNI agents, some storage drivers, node-level debuggers. Treat any request for it in an application manifest as a design question rather than a config tweak.

Similarly, hostNetwork, hostPID and hostIPC on the Pod spec remove the namespace isolation that the rest of these settings assume. A non-root user with hostPID: true can still see every process on the node.

Enforcing it: Pod Security Admission

Writing these settings in one manifest doesn’t stop the next team from leaving them out. Pod Security Admission, built into the API server, enforces one of three standards per namespace by label. The restricted standard requires runAsNonRoot: true, allowPrivilegeEscalation: false, capabilities.drop including ALL (with only NET_BIND_SERVICE allowed back), and a seccomp profile of RuntimeDefault or Localhost, and forbids privileged containers and host namespaces. The example Pod above passes it. A Pod missing one setting is rejected with a message listing every violation, which makes a good checklist. The Pod Security Admission guide covers rolling it out with warn and audit modes first.

Interview practice

“You enable readOnlyRootFilesystem: true, runAsNonRoot: true and drop: [ALL] on an existing nginx Deployment using the official nginx:1.27 image. It fails to start. Walk through the likely errors in order and fix each.”

Answer

The official nginx image starts its master process as root, so the first failure is the kubelet’s check: container has runAsNonRoot and image will run as root. Setting runAsUser: 101 (the image’s nginx user) gets past that, but now nginx tries to bind port 80, which needs NET_BIND_SERVICE that we dropped, so it fails with bind() to 0.0.0.0:80 failed (13: Permission denied). Next, with a read-only root it can’t create its temp directories under /var/cache/nginx or write its PID file to /var/run/nginx.pid, failing with Read-only file system.

The tidy fix is switching to nginxinc/nginx-unprivileged, which runs as UID 101 and listens on 8080, then mounting emptyDir volumes at /var/cache/nginx and /tmp (that image keeps its PID file in /tmp). The Service maps port 80 to targetPort 8080, so clients see no change, and no capability needs adding back. If the team must keep the official image, they’d need a custom config changing the listen port and PID path, which is more work for the same result.

Further reading

More in Kubernetes

esc