Drop root: non-root by default
USER, numeric UIDs, and file ownership.
One line in a Dockerfile buys you more security than any scanner you can buy: do not run the thing as root. That sounds like throwaway advice. It is not. Unless you have switched on a user namespace (a kernel feature that remaps identities, so ID 0 inside the container maps to some harmless ID outside), user ID 0 inside the container is user ID 0 on the host. UID means user ID, the account number the kernel actually checks when it decides what you may touch. Same kernel underneath, same number, same authority. Calling that account "container root" makes it sound local and contained. It is root of the whole machine, and the only thing holding up the sandbox illusion is a filtered view of the filesystem and the process list.
Root is the master key to a building. Namespaces, the isolation that gives a container its own view of processes, mounts and network, only change which hallway the process believes it is standing in. They do not file down the key. So when an attacker gets code execution (RCE = remote code execution, meaning they can run commands of their choosing on your box) inside a root container and then breaks the isolation, they step out onto the host still holding that master key. Drop root first and the identical breakout strands them as UID 10001: none of root's powers, almost nothing they are allowed to touch. The bug is the same either way. What changes is the blast radius, how much of your estate the failure reaches, and that is the difference between a bad afternoon and a rebuilt cluster.
Bake a non-root user into the image
You set this in two places, and you need both, because each one plugs a hole the other leaves open. Start with the image. Create a real account with a fixed, high, numeric ID and switch to it before the entrypoint ever runs.
FROM alpine:3.20# dedicated, fixed-UID account; do not reuse a system UID like 100 or 999RUN addgroup -g 10001 app && adduser -u 10001 -G app -S -D appWORKDIR /appCOPY --chown=10001:10001 . .RUN mkdir -p /var/cache/app && chown 10001:10001 /var/cache/appUSER 10001:10001ENTRYPOINT ["/app/server"]
Use the number, not the name. USER app reads nicely to a human, but a platform cannot verify it. The Kubernetes runAsNonRoot check inspects the numeric UID, and it has no way to know whether the name app resolves to 10001 or to 0. On a scratch base (an image with nothing in it, not even a shell) there is no /etc/passwd file at all, so a name has nothing to resolve against. A number always works. The --chown lines earn their keep for a practical reason: a non-root process can read and write only the files it owns or has been explicitly granted, so you hand it ownership of its own code and its own cache directory at build time. It starts life already able to reach what it needs.
A USER line is a default, not a lock
Here is the trap. USER in a Dockerfile sets the default identity, and a default is exactly the kind of thing that gets quietly overridden. Anyone can type docker run --user 0 and they are root again. A downstream image that starts FROM your-image and slips in a USER root layer for one quick fix ships root to production. Plenty of popular base images still default to root, so an ordinary rebuild can undo your work without printing a single warning. Never assume the image settled the question. Confirm it, then pin it at run time so no single edit can put root back.
# an image with no USER line runs as root: the silent default$ docker run --rm myapp:1.0 iduid=0(root) gid=0(root) groups=0(root)# DETECT whether a USER is actually baked in. empty output = runs as root$ docker inspect --format '{{.Config.User}}' myapp:1.0# FIX: pin the identity at run time, no matter what the image claims$ docker run --rm --user 10001:10001 myapp:1.0 iduid=10001 gid=10001
Make the platform refuse root outright
The image states a preference. The platform can turn it into a rule. In Kubernetes, runAsNonRoot: true tells the kubelet (the agent on every node that actually starts containers) to work out the container's UID before anything runs and to refuse to start it if that UID is 0. This is the control that catches a lying image. An image that quietly reset USER back to root, by accident or on purpose, never executes a single instruction, because the check happens before PID 1 exists. PID means process ID, and PID 1 is the container's very first process. Set the numeric runAsUser as well, so the kubelet has an unambiguous number to test instead of a name it would have to take on trust.
# securityContext demands non-root; the image underneath still resolves to UID 0$ kubectl apply -f pod.yaml && kubectl get pod web -wNAME READY STATUS RESTARTS AGEweb 0/1 CreateContainerConfigError 0 3s$ kubectl describe pod web | grep -A1 WarningWarning Failed container has runAsNonRoot and image will run as root
Dropping root breaks writes. Good.
Expect the first run to break something, and read that as the control doing its job. An app that assumed it could scribble anywhere hits a wall the moment it is not root: a cache directory, a lock file, a Unix socket, and the process falls over with Permission denied. The wrong reflex is to hand root back. The right one is to give the process the exact paths it legitimately writes to and nothing more, either owned by its UID at build time or mounted as a writable tmpfs (a filesystem that lives in RAM and vanishes when the container stops). Every writable location then appears in the run spec, which makes it auditable instead of a mystery.
# a non-root process cannot write into a root-owned path$ docker run --rm --user 10001:10001 myapp:1.0 \sh -c 'echo hi > /app/state'sh: can't create /app/state: Permission denied# DETECT the effective UID straight from the kernel, not from the app's logs$ docker run --rm --user 10001:10001 myapp:1.0 grep -E '^(Uid|Gid)' /proc/self/statusUid: 10001 10001 10001 10001Gid: 10001 10001 10001 10001# FIX: a writable path the UID owns (here an in-RAM tmpfs), not a return to root$ docker run --rm --user 10001:10001 --tmpfs /run:size=8m,uid=10001 \myapp:1.0 sh -c 'echo hi > /run/state && echo wrote /run/state ok'wrote /run/state ok
USER in the Dockerfile is the cheapest control in this course for what it buys you. Prefer numeric IDs so the image does not lean on /etc/passwd entries a distroless base may not have. Fix ownership at build time. Do not start as root at boot for the sole purpose of running chown.
Kubernetes runAsNonRoot, the Docker --user flag and the image itself should all say the same thing. Drift between the Dockerfile USER and the orchestrator securityContext is a classic production surprise, and it tends to surface at the worst possible hour.
Some vendors still ship images that demand root. Isolate those. Put them behind a controller, or run them on dedicated nodes with stronger sandboxing, rather than loosening the baseline for the whole cluster because one appliance cannot behave.
In production, that same check is what you run after a change window. Confirm the control is still on, paste the command and its output into the ticket, and refuse to close the change if the reading moved. Prefer the tightest scope that still lets the workload run. That habit compounds across every host and every pipeline.
Try this
Run a process as numeric UID 65532. Watch a write to / get refused while a write to /tmp goes through. Then run the same image with no --user and see what the default costs you.
$ docker run --rm --user 65532:65532 alpine sh -c 'id; touch /evil 2>&1; touch /tmp/ok 2>&1; ls -l /tmp/ok'uid=65532 gid=65532touch: /evil: Permission denied-rw-r--r-- 1 65532 65532 0 ... /tmp/ok$ docker run --rm alpine sh -c 'id; touch /evil && echo root-can-write'uid=0(root) gid=0(root)root-can-write
Takeaway
Non-root by default, numeric UIDs, ownership fixed at build time. If a vendor demands root, quarantine that workload rather than lowering the baseline for everyone else.
USER 10001 rather than USER app. Why does the number matter for enforcement?USER 10001 and enforce runAsNonRoot: true, and the app dies on startup writing to a mounted data volume that was created owned by root. What is the right fix?