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: b6b034cf084eaae1d032e9a03e4422a1ba60bb332217b75a5c39bc779356f16a

docker run --user 10001 alpine:3.24 id prints uid=10001 gid=0(root) groups=0(root). The process isn't root, but its group is 0. Every file it creates belongs to group root, and it can write anything that's group-writable for root. It's a small example of how "runs as non-root" is really decided by a pair of numbers. You set them in the image or at run time, and the kernel compares them 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. Here you build 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.

You'll 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.24
# 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 fixed IDs, no password and no home directory. 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. It also refuses one whose user is a name it can't verify. Build the image and read back what Docker stored:

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

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

ubuntu@secopslog-docker:~ · Docker 29.9.2
$ printf 'FROM alpine:3.24\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:4b11f917b51ac0fa50982a9446db55e0d819dec04d8644b6348c7053a0e2cedb [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 and Config.User holds the whole line. The container can't 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.9.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-26T07:35:37Z started as uid=10001 gid=10001 2026-10-26T07:35:37Z started as uid=10001 gid=10001 2026-10-26T07:35:37Z 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 26 07:35 /data -rw-r--r-- 1 10001 10001 104 Oct 26 07:35 /data/starts.log

The first run printed one line, the second printed two, both as uid=10001 gid=10001. Why did the write work on a brand-new volume? Docker copied the owner and mode of the image's /data into the empty volume at its first mount. That's why the Dockerfile creates and chowns /data at all. Two harder cases are a volume that root wrote first and a bind mount, where the host directory's owner applies unchanged. "Volumes, bind mounts and tmpfs in practice" in Docker in depth covers both, 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.9.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 26 07:35 app.sh sh: can't create /app/app.sh: Permission denied

UID 10001 can run /app/app.sh but can't change it. That's the intended state. A process that's compromised through a bug can't 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.24
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.9.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:58d6887c5e0a7f08c1dd03051827d21a4d1d8dabe0c544a8bfa695a1d746b41e total 4 -rwxr-xr-x 1 10001 10001 200 Oct 26 07:35 app.sh # changed

Now the process owns its code and can modify it. Inside a running container, a change made this way survives restarts and lasts until you remove the container. 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.9.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.24 id docker run --rm --user 10001:10001 alpine:3.24 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 your 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 and the result won't depend on what the image contains. That's --user 10001:10001 on the command line and user: "10001:10001" in Compose.

Images that start as root on purpose

An empty Config.User doesn't always mean the service runs as root. Several official images start as root so their entrypoint can fix the ownership of a data directory. Then they switch user before starting the server:

ubuntu@secopslog-docker:~ · Docker 29.9.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 175916 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. That 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. In an audit, the host's ps (or /proc/<pid>/status) tells you which UID it's running as. Config.User only tells you what the image asked for. "Production best practices" in Docker in depth turns both into a pre-ship check.

Remove everything:

ubuntu@secopslog-docker:~ · Docker 29.9.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:b47629f9b965a59cd188b19b6354a85bdf2e937ed5fe25bd368ea768961c27e6 Untagged: lab-nonroot:chown Deleted: sha256:58d6887c5e0a7f08c1dd03051827d21a4d1d8dabe0c544a8bfa695a1d746b41e Untagged: lab-nonroot:comment Deleted: sha256:4b11f917b51ac0fa50982a9446db55e0d819dec04d8644b6348c7053a0e2cedb

Try this

Copy the lesson files back to ~/lab/nonroot on the main lab VM and rebuild lab-nonroot:1. Then run docker run --rm --user 10001 lab-nonroot:1 id and predict the group before you press Enter. This image has a passwd entry for 10001, unlike plain alpine. Compare your answer with the gid=0(root) line from the nogroup step, then remove the image again.

Takeaway

Write the user as two numbers, USER 10001:10001 in the image and --user 10001:10001 at run time, and keep the code owned by root. Reviews miss the quiet failures: --user 10001 on plain alpine still ran with gid=0(root), and a trailing comment on USER built fine but failed at docker run with exit 125. Check the running UID from the host with ps, because Config.User was empty for redis even though it ran as 999.

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.

Related