Articles / Why Running Containers as Root Is Still the #1 Mistake
Containers · Hardening

Why Running Containers as Root Is Still the #1 Mistake

A container is not a security boundary in the way a virtual machine is. It is a process on the host kernel wearing a set of namespaces and cgroups. So when that process runs as root — which, by default, it does — you are one kernel bug or one misconfiguration away from root on the host. Running as root remains the most common container hardening failure because nothing forces you to fix it, and everything works fine until it doesn't.

When you write a Dockerfile with no USER instruction, the resulting container runs its main process as UID 0. That UID is the same UID 0 the kernel knows on the host; user namespaces, which would remap it, are off by default in most runtimes. Inside the container the process has a restricted capability set, but 'restricted' is not 'none', and the attack surface it shares with every other tenant is the host kernel itself.

What root inside a container actually buys an attacker

RCE in the appAttackerApp processrunning as UID 0Full root caps+ host mounts, secretsShared host kernelruntime bug / misconfigRoot on the hostRCE inherits root →escape is cheap
How UID 0 in a container turns one app bug into root on the host.

Suppose an attacker achieves remote code execution in your application — a deserialization bug, an SSRF that reaches an internal admin endpoint, whatever. If the process is root, they inherit its capabilities. They can write to any bind-mounted host path the container was granted, read secrets mounted into the filesystem, install tooling, and probe for a kernel or runtime flaw to break out entirely. Escapes such as the 2019 runc overwrite (CVE-2019-5736) and the 2024 'Leaky Vessels' runc flaw (CVE-2024-21626) were dramatically easier to weaponise from a root process than from an unprivileged one.

Run the same process as an unprivileged UID and most of that collapses. The attacker cannot write to root-owned host mounts, cannot bind low ports, and hits permission denials on the very syscalls an escape needs. It is not a guarantee, but it removes the cheapest paths first.

Why it matters: Root in a container is root on the host kernel, minus a few capabilities. Dropping to a non-root UID is the single highest-leverage change you can make to a container image, and it costs one line.

Fixing it in the image and at admission

  • Set a numeric USER in the Dockerfile. Prefer a high, explicit UID (e.g. USER 10001) over a username, so admission policies that check runAsNonRoot can evaluate it without resolving /etc/passwd.
  • Enforce it in the pod spec. Set securityContext.runAsNonRoot: true and a concrete runAsUser; the kubelet will refuse to start a container that tries to run as UID 0.
  • Drop capabilities to the floor. Add capabilities.drop: ["ALL"] and add back only what the workload provably needs. Most web services need none.
  • Make the root filesystem read-only. readOnlyRootFilesystem: true plus explicit emptyDir writable mounts stops an attacker planting a binary where the app will re-execute it.
  • Block privilege escalation. allowPrivilegeEscalation: false prevents a setuid binary inside the image from regaining capabilities the runtime dropped.
The whole fix, in a Dockerfile and a pod securityContext# Dockerfile
RUN adduser -D -u 10001 app
USER 10001

# pod spec
securityContext:
  runAsNonRoot: true
  runAsUser: 10001
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities: { drop: [ "ALL" ] }

Why it persists

The reason root-by-default survives is friction, not ignorance. A base image expects to write to /var; an init script wants to chown; a legacy service binds port 80. Each is solvable — pre-create writable dirs, bind a high port and remap it at the service, use file capabilities instead of full root — but each takes a few minutes, and 'it works' is a powerful anaesthetic. The fix is to make non-root the default your platform ships, so every new image inherits it and opting back into root is the exception that gets reviewed.

None of this matters for the workloads you don't know are running. A team that spun up a container on a forgotten node, as root, with a host mount, is exactly the blind spot an attacker finds first — and you can't harden what you haven't discovered.

Test for it — in practice
  • Pick a few running containers and check the effective UID of the main process (docker inspect, or the pod's securityContext / id inside the container). Anything sitting at UID 0 is running as root and worth flagging.
  • Skim your Dockerfiles for a missing USER line — no USER means the image defaults to root.

Spot-checking a handful proves the point; enumerating every workload across every cluster and ranking the real blast radius is what our assessment runs for you.

Keep reading
Minimal and Distroless Base Images: Shrinking the Attack Surface