Run as non-root

A numeric USER, code the app cannot change, and the run-time overrides that undo it.

Advanced12 min · lesson 10 of 24
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (3 files, 1 KB): nonroot.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-int/nonroot.tar.gz && tar -xzf nonroot.tar.gz, which creates ~/lab/nonroot/. SHA-256: 5d60386ef52fa27ee501535babd0069887fbabc282e8c33b37c4943cf2157c92

docker run --user 10001 alpine:3.22 id prints uid=10001 gid=0(root) groups=0(root). The process is not root, but its group is 0, so every file it creates belongs to group root and it can write anything that is group-writable for root. It is a small example of how "runs as non-root" is really decided by a pair of numbers, set in the image or at run time, that the kernel compares with the numbers stored on files. "What root in a container really is" showed why UID 0 in a container is UID 0 on the host. This lesson builds the fix, an image that runs as a fixed UID and GID, keeps its own code out of its reach, and still writes where it has to.

Use the main lab VM. The lesson files go in ~/lab/nonroot: a small shell "app" that appends a line to /data/starts.log each time it starts, and two Dockerfiles for it.

app.sh
#!/bin/sh
# lab app: appends one line per start to /data/starts.log, then prints the log
set -e
echo "$(date -u +%FT%TZ) started as uid=$(id -u) gid=$(id -g)" >> /data/starts.log
cat /data/starts.log

A numeric USER in the image

Dockerfile
FROM alpine:3.22
# a service account with fixed numeric IDs, and the one directory it may write
RUN addgroup -S -g 10001 app \
&& adduser -S -D -H -u 10001 -G app app \
&& mkdir /data \
&& chown 10001:10001 /data
# the code stays owned by root: the app can run it but not change it
COPY --chmod=0755 app.sh /app/app.sh
USER 10001:10001
CMD ["/app/app.sh"]

addgroup -S and adduser -S -D -H create a system account with no password and no home directory, with fixed IDs. Pick IDs that mean nothing on the hosts the image will run on. Without a user namespace the number crosses unchanged, so 1000 would be the first login account on most machines, and 100 to 999 belong to distribution service accounts. 10001 is a common choice; distroless images use 65532 for their nonroot user ("Minimal bases: distroless, scratch, static and Alpine"). mkdir /data and chown give the account exactly one directory it owns.

USER 10001:10001 uses numbers rather than app. A name is resolved through the image's /etc/passwd when the container starts, and a scratch or distroless image may not have one. A number also lets tools check the claim without opening the image. Kubernetes is the common example: with runAsNonRoot: true the kubelet refuses to start a container whose user is 0, and also one whose user is a name it cannot verify. Build the image and read back what Docker stored:

ubuntu@secopslog-docker:~/lab/nonroot · Docker 29.8.2
$ docker build -q -t lab-nonroot:1 .
sha256:7cc171c40fd4392f4d4cf2a633e3da96e71036ab01c33ea8de8e3cf36052fc94
$ docker image inspect -f '{{.Config.User}}' lab-nonroot:1
10001:10001

Keep Dockerfile comments on their own lines. A comment after an instruction is not a comment, and for USER it becomes part of the user string:

ubuntu@secopslog-docker:~ · Docker 29.8.2
$ printf 'FROM alpine:3.22\nUSER 10001:10001 # the app user\n' | docker build -q -t lab-nonroot:comment - docker image inspect -f '[{{.Config.User}}]' lab-nonroot:comment docker run --rm lab-nonroot:comment id
sha256:ad29ce9757d714e2c5294d405547306a323dc3fdd266d8282646b9fc6e6e5953 [10001:10001 # the app user] docker: Error response from daemon: unable to find group 10001 # the app user: no matching entries in group file Run 'docker run --help' for more information

The build succeeds, Config.User holds the whole line, and the container cannot start because no group is called 10001 # the app user. Nothing warns at build time, so this kind of mistake reaches a registry. Now run the real image twice with a named volume on /data:

ubuntu@secopslog-docker:~/lab/nonroot · Docker 29.8.2
$ docker run --rm -v lab-nonroot-data:/data lab-nonroot:1 docker run --rm -v lab-nonroot-data:/data lab-nonroot:1
2026-10-07T20:07:26Z started as uid=10001 gid=10001 2026-10-07T20:07:26Z started as uid=10001 gid=10001 2026-10-07T20:07:26Z started as uid=10001 gid=10001
$ docker run --rm -v lab-nonroot-data:/data lab-nonroot:1 ls -lnd /data /data/starts.log
drwxr-xr-x 2 10001 10001 4096 Oct 7 20:07 /data -rw-r--r-- 1 10001 10001 104 Oct 7 20:07 /data/starts.log

The first run printed one line, the second printed two, both as uid=10001 gid=10001. The write worked on a brand-new volume because Docker copied the owner and mode of the image's /data into the empty volume at its first mount, which is why the Dockerfile creates and chowns /data at all. What happens with a volume that root wrote first, and with bind mounts, where the host directory's owner applies unchanged, is covered in "Volumes, bind mounts and tmpfs in practice" in Docker in depth, along with the fixes. None of them involves running the app as root again.

Code the app cannot change

COPY without --chown leaves files owned by root. --chmod=0755 makes the script executable for everyone and writable only by root:

ubuntu@secopslog-docker:~/lab/nonroot · Docker 29.8.2
$ docker run --rm lab-nonroot:1 sh -c 'ls -ln /app; echo "# changed" >> /app/app.sh'
total 4 -rwxr-xr-x 1 0 0 200 Oct 7 19:20 app.sh sh: can't create /app/app.sh: Permission denied

UID 10001 can run /app/app.sh but cannot change it. That is the intended state. A process that is compromised through a bug cannot rewrite the code it runs, and anything it does write lands only in /data, where you expect writes. A common shortcut gives the whole application directory to the service account:

Dockerfile.chown
FROM alpine:3.22
RUN addgroup -S -g 10001 app \
&& adduser -S -D -H -u 10001 -G app app \
&& mkdir /data \
&& chown 10001:10001 /data
# the common shortcut: hand the whole app directory to the service account
COPY --chown=10001:10001 --chmod=0755 app.sh /app/app.sh
USER 10001:10001
CMD ["/app/app.sh"]
ubuntu@secopslog-docker:~/lab/nonroot · Docker 29.8.2
$ docker build -q -f Dockerfile.chown -t lab-nonroot:chown . docker run --rm lab-nonroot:chown sh -c 'ls -ln /app; echo "# changed" >> /app/app.sh && tail -1 /app/app.sh'
sha256:8d164f92ab07bf695f040be5f0f5bee2049881390e6da7d250fc73f2ead1a055 total 4 -rwxr-xr-x 1 10001 10001 200 Oct 7 19:20 app.sh # changed

Now the process owns its code and can modify it. Inside a running container, a change made this way lasts until the container is removed, and it survives restarts. Use --chown for directories the app must write, not for the code itself. If a framework insists on writing next to its code, give it that one subdirectory.

USER is a default, and so is the group

Whoever starts the container can replace the image's choice:

ubuntu@secopslog-docker:~/lab/nonroot · Docker 29.8.2
$ docker run --rm --user 0 lab-nonroot:1 id
uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
$ docker run --rm --user 10001 alpine:3.22 id docker run --rm --user 10001:10001 alpine:3.22 id
uid=10001 gid=0(root) groups=0(root) uid=10001 gid=10001 groups=10001

--user 0 turns the same image back into a root container with root's supplementary groups. Docker Engine has no daemon setting that refuses UID 0 containers, so on plain Docker the controls are review and checks: look for --user and Compose user: values in the files that start services, and read Config.User from images in CI. User-namespace remapping and rootless Docker change what UID 0 means instead ("User-namespace remapping", "Rootless Docker").

The second pair shows the opening example. With --user 10001 alone, Docker looks the UID up in the image's /etc/passwd to find a primary group. Plain alpine has no entry for 10001, so the group falls back to 0. Inside lab-nonroot:1, which has the entry, --user 10001 would get group 10001. Pass both numbers, --user 10001:10001 on the command line and user: "10001:10001" in Compose, and the result does not depend on what the image contains.

Images that start as root on purpose

An empty Config.User does not always mean the service runs as root. Several official images start as root so their entrypoint can fix the ownership of a data directory, and then switch user before starting the server:

ubuntu@secopslog-docker:~ · Docker 29.8.2
$ docker run -d --name lab-redis redis:8-alpine >/dev/null sleep 2 docker image inspect -f 'image USER=[{{.Config.User}}]' redis:8-alpine ps -o user=,pid=,comm= -p $(docker inspect -f '{{.State.Pid}}' lab-redis) docker exec lab-redis grep -m1 setpriv /usr/local/bin/docker-entrypoint.sh
image USER=[] 999 537554 redis-server SETPRIV="/bin/setpriv --reuid redis --regid redis --clear-groups"

The redis image has no USER, yet the host sees redis-server running as UID 999. Its entrypoint uses setpriv to switch to the redis user, which needs the SETUID and SETGID capabilities in the container's default set. During that first moment the process is root, so this pattern still depends on the rest of the hardening: dropped capabilities, no-new-privileges and the default security profiles. For an audit, the host's ps (or /proc/<pid>/status) answers "which UID is it running as", while Config.User answers only "what did the image ask for". "Production best practices" in Docker in depth turns both into a pre-ship check.

Remove everything:

ubuntu@secopslog-docker:~ · Docker 29.8.2
$ docker rm -f lab-redis docker volume rm lab-nonroot-data docker rmi lab-nonroot:1 lab-nonroot:chown lab-nonroot:comment rm -rf ~/lab/nonroot
lab-redis lab-nonroot-data Untagged: lab-nonroot:1 Deleted: sha256:7cc171c40fd4392f4d4cf2a633e3da96e71036ab01c33ea8de8e3cf36052fc94 Untagged: lab-nonroot:chown Deleted: sha256:8d164f92ab07bf695f040be5f0f5bee2049881390e6da7d250fc73f2ead1a055 Untagged: lab-nonroot:comment Deleted: sha256:ad29ce9757d714e2c5294d405547306a323dc3fdd266d8282646b9fc6e6e5953
Quick check
01A service is started with docker run --user 10001 alpine-based-image. The image has no /etc/passwd entry for 10001. Files the service creates in a shared volume show up as owned by 10001:0. Why group 0?
Incorrect — Docker adds no such membership; with --user 10001:10001 the lab shows groups=10001 only.
Correct — The lab shows uid=10001 gid=0(root) for this case. Pass --user 10001:10001 to make the group explicit.
Incorrect — A setgid directory would explain it for one volume, but the GID 0 shows up in id before any file is written.
Incorrect — The kernel has no passwd file; it uses whatever numbers it is given. Docker chose the GID.
02An image ends with COPY --chown=10001:10001 . /app and USER 10001:10001. A reviewer asks for the --chown to be removed from the code copy. What risk is the reviewer pointing at?
Incorrect — Ownership does not remove read permission for others, and root passes read checks anyway.
Incorrect — COPY with --chown writes the files once with the given owner; there is no second copy.
Incorrect — No such rule exists; runAsNonRoot checks the user the process runs as, not file ownership.
Correct — The lab appends to /app/app.sh as 10001 after --chown and is refused without it.
03A Dockerfile ends with USER 10001:10001 # app user. The build succeeds, but docker run fails with "unable to find group". What went wrong?
Correct — Dockerfile comments must start a line; the lab shows Config.User holding the whole string and docker run failing with "unable to find group".
Incorrect — A bare numeric GID works without any /etc/group entry; --user 10001:10001 on plain alpine runs fine in the lab.
Incorrect — There is no such limit; the lab runs UID 10001 and distroless uses 65532.
Incorrect — USER accepts any number; the failure here is the trailing text, not the ID.

Try this

Work through “Images that start as root on purpose” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

If you keep one thing from run as non-root, keep “Images that start as root on purpose”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.

Related