Ch. 13 · Docker

Docker Nonroot Users and File Permissions

Switching a container to a nonroot USER breaks writes until ownership is right. Fix EACCES with chown, numeric UIDs and pre-created volume paths.

~5 min readintermediateupdated Oct 7, 2026

Most base images run as root because that’s what package installation needs. Most applications don’t need root at all once they’re installed. Adding a USER line is one of the cheapest hardening steps you can make to an image, and it nearly always breaks something the first time, because the files the app writes were created by root during the build.

The experiments here use node:22-bookworm, which ships a ready-made node user with UID 1000, and a small script that writes a file and listens on port 80.

Why root in a container is worth avoiding

Containers share the host’s kernel. Unless the daemon is configured with user namespace remapping (rare outside hardened setups) or you’re running rootless Docker, UID 0 inside the container is UID 0 to the kernel. Docker limits what that root can do: it drops most Linux capabilities, applies a seccomp profile and isolates namespaces. You can see the difference capabilities make:

$ docker run --rm node:22-bookworm sh -c 'id; grep CapEff /proc/self/status'
uid=0(root) gid=0(root) groups=0(root)
CapEff:	00000000a80425fb
$ docker run --rm -u node node:22-bookworm grep CapEff /proc/self/status
CapEff:	0000000000000000
Text

Even the restricted root keeps capabilities like CAP_CHOWN, CAP_DAC_OVERRIDE (ignore file permissions) and CAP_SETUID. A nonroot process has none. That matters in three situations interviewers usually want named: a kernel or runtime vulnerability that lets a process escape, a careless bind mount of a host directory, and an application bug like path traversal that lets an attacker write files. In all three, starting as an unprivileged user turns a disaster into a contained incident.

Another reason is plain policy. Kubernetes runAsNonRoot: true, OpenShift’s default security constraints and many platform teams reject images that run as root.

The first USER line and the EACCES that follows

Here’s the naive version. The app wants to write into /app/uploads:

FROM node:22-bookworm
WORKDIR /app
COPY app.js .
RUN mkdir uploads
USER node
CMD ["node", "app.js"]
dockerfile
$ docker run --rm nr-bad
EACCES EACCES: permission denied, open '/app/uploads/test.txt'
listening on 80 as uid 1000
Text

WORKDIR, COPY and RUN all executed as root, so /app/uploads is owned by root with mode 755. USER only affects instructions after it and the running container. The fix is to give the app ownership of exactly the paths it writes, and nothing else:

FROM node:22-bookworm
WORKDIR /app
COPY --chown=root:root app.js .
RUN mkdir uploads && chown node:node uploads
USER node
CMD ["node", "app.js"]
dockerfile
$ docker run --rm nr-good
write ok
listening on 80 as uid 1000
$ docker run --rm nr-good sh -c 'ls -ld /app /app/uploads /app/app.js; echo hack >> /app/app.js'
drwxr-xr-x 1 root root 4096 Oct  7 17:23 /app
-rw-r--r-- 1 root root  322 Oct  7 17:23 /app/app.js
drwxr-xr-x 2 node node 4096 Oct  7 17:23 /app/uploads
sh: 1: cannot create /app/app.js: Permission denied
Text

That last line is the point. The tempting shortcut is COPY --chown=node:node . . or RUN chown -R node /app, which makes the errors go away by letting the process rewrite its own code. Keeping code root-owned means a compromised app can’t modify itself or plant files that survive a restart. Also note that RUN chown -R on a large directory duplicates every file into a new layer, roughly doubling the image size for that path; COPY --chown sets ownership in the same layer.

Ports below 1024 aren’t the obstacle they used to be

The output above shows UID 1000 listening on port 80. Traditionally only root could bind ports below 1024, and older advice says to run nginx or Node on 8080 for that reason. Docker Engine (since 20.10) sets net.ipv4.ip_unprivileged_port_start=0 inside each container’s network namespace:

$ docker run --rm nr-good cat /proc/sys/net/ipv4/ip_unprivileged_port_start
0
Text

So the low-port argument no longer forces root under Docker. Other runtimes and Kubernetes setups may not set that sysctl, which is why many images still default to 8080; using a high port is still the portable choice.

Volumes, bind mounts and numeric IDs

Volumes bring ownership back into play. A brand-new named volume takes the ownership and mode of the image directory it’s mounted over, so pre-creating the mount point in the Dockerfile matters:

$ docker run --rm -v uploads:/app/uploads nr-good sh -c 'ls -ld /app/uploads; touch /app/uploads/x && echo volume writable'
drwxr-xr-x 2 node node 4096 Oct  7 17:23 /app/uploads
volume writable
$ docker run --rm -v other:/data nr-good touch /data/x
touch: cannot touch '/data/x': Permission denied
Text

/data didn’t exist in the image, so Docker created it as root. If you see a nonroot app fail only when a volume is attached, check whether its mount point exists in the image with the right owner. And the seeding happens only once: a volume created by an old root-running version of the image keeps root ownership after you add USER, which surprises teams on their first nonroot release. A one-off docker run --rm -u 0 -v uploads:/d image chown -R 1000:1000 /d migrates it.

Bind mounts follow the host’s numeric IDs instead, covered in the bind mounts note.

The kernel only cares about numbers. You can run any UID, even one with no /etc/passwd entry:

$ docker run --rm -u 4242 alpine:3.20 sh -c 'id; echo HOME=$HOME'
uid=4242 gid=0(root) groups=0(root)
HOME=/
Text

Note the group is still 0 and HOME is /, which is not writable. Tools that write to ~/.cache or ~/.npm fail in this mode, so set HOME explicitly when you run arbitrary UIDs (OpenShift does this by default). Prefer USER 1000:1000 over USER node in the Dockerfile, because Kubernetes can only verify runAsNonRoot when the image config has a numeric UID; with a name, the kubelet can’t tell whether it maps to 0 and refuses to start the pod.

Note

docker run -u 0 overrides the image’s USER for debugging. That’s fine on your laptop, and a reason why production policy should enforce nonroot at the platform level instead of trusting the image alone.

Interview practice

You add USER 10001 to the last stage of a Python service’s Dockerfile. In local tests it works. In staging, which mounts an existing named volume at /var/lib/app and sets --read-only, the service crashes with PermissionError: [Errno 13] Permission denied: '/var/lib/app/state.db' and, after fixing that, with an error writing to /root/.cache. Walk through both failures.

Answer

The staging volume already existed from the previous image version, which ran as root, so its files and directory are owned by UID 0. A volume is only seeded from the image when it’s empty, so the new image’s ownership doesn’t apply. UID 10001 can read but not write the database file. Fix it with a one-off chown of the volume contents to 10001 (or an init step that does so as root before the app starts), and make sure the Dockerfile creates /var/lib/app owned by 10001 so new environments start correct.

The second error comes from the home directory. The image still has HOME=/root from the base, or the library falls back to it, and /root isn’t writable by 10001; with --read-only nothing outside mounted paths is writable anyway. Set HOME (or the library’s cache variable, such as XDG_CACHE_HOME) to a path backed by a tmpfs or volume. A good answer also notes that code should stay root-owned and only /var/lib/app and the cache path should be writable by the service user.

Keep going

More in Docker

read ✓Docker · mid

Docker Bind Mounts and Host Coupling

Bind mounts map a host path into a container: great for live code, risky for ownership, portability and security. See where each one bites.

~6 min readread →
read ✓Docker · easy

Docker Entrypoint and Command Overrides

How ENTRYPOINT and CMD combine into one argv, what docker run arguments replace, and why appending sh to some images never gives you a shell.

~6 min readread →
esc