Why should containers avoid running as root, and how do you enforce it?
Quick Answer
By default, a container's main process runs as root (UID 0) unless explicitly configured otherwise — if an attacker achieves code execution inside such a container, they have full root privileges within that container's namespace, which meaningfully increases what they can do if they then find a way to escalate further (a container escape, or simply damage within the container's own filesystem/resources). Enforce non-root execution with the Dockerfile's USER instruction (or an equivalent runtime flag), following the same least-privilege principle covered throughout this stack's other security-focused questions.
Detailed Answer
The default, risky behavior
FROM node:20-slim
WORKDIR /app
COPY . .
CMD ["node", "server.js"] # runs as ROOT by default -- no USER instruction specified
Without an explicit USER instruction, most base images default their main process to running as root (UID 0) inside the container. This isn't automatically catastrophic. The container's root is still confined by its namespace, and with default settings it doesn't have direct root access to the host. But it meaningfully raises the stakes of anything going wrong. An attacker who achieves arbitrary code execution inside a root-running container has unrestricted access to everything within that container: every file, every process, the ability to install anything. The same compromise inside a non-root container is confined to whatever that specific, limited user account can actually do.
Enforcing non-root with the USER instruction
FROM node:20-slim
WORKDIR /app
COPY --chown=node:node . .
USER node # many official images already include a pre-created, unprivileged user
CMD ["node", "server.js"]
Many official base images (like node) already include a pre-created, unprivileged user for this purpose, conventionally also named after the runtime (like node). Using USER node switches to it. From that point in the Dockerfile onward, the container's main process, and anything it forks, runs without root privileges.
Creating your own non-root user, for images that don't provide one
FROM alpine:3.19
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
For base images without a suitable pre-existing user, creating a dedicated, minimal, non-privileged user explicitly (with no unnecessary permissions or shell access beyond what's needed) is the standard practice.
Enforcing this at runtime, independent of what the image itself declares
docker run --user 1000:1000 myapp:1.0
The --user flag on docker run (or the equivalent securityContext.runAsUser/runAsNonRoot in Kubernetes — see that stack's SecurityContext question) can force a specific non-root UID, even for an image that would otherwise default to root. This provides an additional, deployment-time layer of enforcement that doesn't rely solely on the image's own Dockerfile having done the right thing.
Why this is worth enforcing even though container isolation exists
Namespaces and cgroups (see the fundamentals topic) provide real isolation, but they are not an absolute security boundary. Container escape vulnerabilities are not routine, but they do periodically get discovered. Running as root inside the container is precisely the condition that makes many such escapes more dangerous or more likely to succeed. Several known escape techniques specifically rely on the compromised process already having root privileges within its own namespace as a stepping stone. Running as a genuinely unprivileged, non-root user is a foundational defense-in-depth measure. It doesn't eliminate the risk of a container escape, but it substantially narrows what an attacker can do both before and during an escape attempt.
The broader principle this connects to
This is the same least-privilege principle that runs throughout this stack's other security questions (Linux capabilities, the Docker socket risk, RBAC in the Kubernetes stack). Grant only the minimum privilege actually needed, so that any single compromise's blast radius is as limited as possible. Don't assume a compromise will never happen and skip limiting its impact if it does. A quick image-review check worth making habitual: docker inspect --format='{{.Config.User}}' myapp:1.0 should show something other than empty/root.