Running Containers as Non-Root
Running a container as non-root means the main process inside the container does not run as user ID 0. This matters because containers share the host kernel, and a process running as root inside a container often has more power than it needs.
Non-root containers are not a complete security boundary, but they reduce the damage from application bugs, leaked shells, writable mounts, and accidental file ownership problems. The goal is simple: give the container process only the permissions it needs to do its job.
Overview: How Non-Root Containers Work
A Docker image is a read-only template made from stacked filesystem layers and metadata. A container is an instance of that image with a thin writable layer, runtime settings, mounts, namespaces, cgroups, and a running process. The user that runs that process is runtime configuration, usually chosen by the image’s USER instruction or overridden when the container is created.
Inside Linux, every process has a numeric user ID, commonly shortened to UID, and a numeric group ID, or GID. Root is UID 0. Many base images start as root because package installation, directory creation, and ownership changes often require root during the build. That is acceptable during image construction, but the final runtime process should usually switch to a less privileged user.
The important detail is that Docker does not create a tiny virtual machine with a separate kernel for each container. On native Linux Docker Engine, container processes are regular Linux processes isolated with namespaces and restricted with cgroups. On Docker Desktop for macOS and Windows, Linux containers run inside a managed Linux VM, but the same container UID model applies inside that VM. If a process breaks out of a weak container boundary, or if it can write to a sensitive bind mount, running as root makes the consequences worse.
By default, Docker uses the image’s configured user. If no user is configured, the process runs as root. You can set the default with USER in a Dockerfile, override it once with docker run --user, or record it in Compose with the service-level user key. Numeric IDs are often more predictable than names because names require matching entries in /etc/passwd inside the image.
Non-root execution also changes file permissions. A process running as UID 10001 cannot write to a directory owned by root unless permissions allow it. That is the most common source of broken non-root images: the app starts with reduced privilege, then fails to write logs, caches, uploads, or temporary files. The fix is not to go back to root; the fix is to create the needed directories and assign ownership during the build.
Syntax
USER USER[:GROUP]
docker run --user UID[:GID] IMAGE[:TAG] [COMMAND] [ARG...]
services:
SERVICE_NAME:
image: IMAGE:TAG
user: "UID:GID"
| Form | Where used | Meaning |
|---|---|---|
USER app |
Dockerfile | Runs following Dockerfile instructions and the default container command as the named user. |
USER 10001:10001 |
Dockerfile | Uses numeric UID and GID. This is predictable even if a host has different account names. |
--user 1000:1000 |
docker run |
Overrides the image’s configured user for this container. |
user: "1000:1000" |
Compose | Records the runtime user in the service definition. |
COPY --chown=user:group |
Dockerfile | Copies files into the image with ownership already set for the runtime user. |
Put Docker runtime flags before the image name. Anything after the image name becomes the command inside the container. In Dockerfiles, do privileged setup first, then switch to the non-root user near the end, before CMD or ENTRYPOINT.
Examples
Example 1: Run a One-Off Container with a Numeric User
docker run --rm --user 1000:1000 alpine:3.20 id
Output:
uid=1000 gid=1000 groups=1000
The Alpine image normally runs as root unless told otherwise. The --user 1000:1000 flag tells Docker to start the process with UID 1000 and GID 1000. The user does not need a username inside /etc/passwd for the kernel to enforce the numeric identity.
Example 2: Build an Image That Defaults to a Non-Root User
FROM node:20-alpine
WORKDIR /app
RUN addgroup -S app && adduser -S -G app app
COPY --chown=app:app package*.json ./
RUN npm ci --omit=dev
COPY --chown=app:app . .
USER app
EXPOSE 3000
CMD ["node", "server.js"]
Build and run the image:
docker build -t node-nonroot-demo:1.0 .
docker run --rm node-nonroot-demo:1.0
Output:
Server listening on port 3000
This Dockerfile uses the pinned base image node:20-alpine instead of node or latest, which makes rebuilds more reproducible. It creates an app user while still root, copies files with --chown=app:app, installs dependencies, then switches to USER app for runtime. EXPOSE 3000 is only documentation and image metadata; it does not publish the port to the host. Use docker run -p 3000:3000 or Compose ports when you need host access.
Example 3: Use Non-Root with Compose and a Writable Volume
services:
worker:
image: alpine:3.20
user: "10001:10001"
command: sh -c 'date >> /data/run.log && tail -n 1 /data/run.log'
volumes:
- worker-data:/data
volumes:
worker-data:
Start the service:
docker compose up --abort-on-container-exit
Output:
worker-1 | Mon Aug 3 12:00:00 UTC 2026
worker-1 exited with code 0
This records the runtime UID and GID in the Compose file, so every teammate gets the same container identity. A named volume is managed by Docker and is usually better for persistent container data than binding a random host directory. If this service could not write to /data, you would fix the volume initialization or directory ownership, not remove the user setting.
How It Works Step by Step
- The Docker client sends a build, run, or Compose request to the Docker daemon.
- During the build, Docker executes Dockerfile instructions as root until a
USERinstruction changes the user for later instructions. - The image configuration records the final default user. That metadata becomes part of the image, alongside environment variables, exposed ports, and the default command.
- When Docker creates a container, it mounts the image’s read-only layers plus the container’s thin writable layer, then applies runtime settings such as mounts, networking, cgroups, capabilities, and user identity.
- If
docker run --useror Composeuseris set, that runtime value overrides the image default for the container. - The kernel starts the container process with the selected UID and GID inside the container’s namespaces. Filesystem permissions are checked against those numeric IDs.
- If the process writes to the container filesystem, data goes into the writable layer. If it writes to a volume or bind mount, ownership and permissions on that mounted filesystem matter.
- When the container exits, its runtime user setting disappears with that container. The image still keeps whatever default
USERmetadata it was built with.
Docker can also run in rootless mode or with user namespace remapping, which changes how container UIDs map to host UIDs. Those features strengthen host isolation, but they do not replace application-level least privilege. You should still run the application process as non-root inside the container.
Common Mistakes
Switching to Non-Root Before Privileged Setup
FROM node:20-alpine
USER app
RUN npm ci --omit=dev
CMD ["node", "server.js"]
This is wrong for two reasons. The app user has not been created, and dependency installation may need directories that the user cannot write. Create the user and prepare ownership first, then switch users:
FROM node:20-alpine
WORKDIR /app
RUN addgroup -S app && adduser -S -G app app
COPY --chown=app:app package*.json ./
RUN npm ci --omit=dev
COPY --chown=app:app . .
USER app
CMD ["node", "server.js"]
Forgetting File Ownership
FROM alpine:3.20
RUN addgroup -S app && adduser -S -G app app
RUN mkdir /data
USER app
CMD ["sh", "-c", "echo hello > /data/message.txt"]
This builds, but the container fails because /data is owned by root. Fix ownership before switching users:
FROM alpine:3.20
RUN addgroup -S app && adduser -S -G app app
RUN mkdir /data && chown app:app /data
USER app
CMD ["sh", "-c", "echo hello > /data/message.txt && cat /data/message.txt"]
Binding to Privileged Ports as Non-Root
docker run --rm --user 10001:10001 python:3.12-alpine python -m http.server 80
On many Linux configurations, binding to ports below 1024 requires extra privilege. Prefer listening on an unprivileged container port and mapping the host port:
docker run --rm --user 10001:10001 -p 8080:8080 python:3.12-alpine python -m http.server 8080
Assuming Non-Root Makes Everything Safe
docker run --rm --user 10001:10001 -v /:/host alpine:3.20 sh -c 'ls /host'
This still gives the container a dangerous view of the host filesystem through a bind mount. Non-root helps, but it does not make unsafe mounts, excessive Linux capabilities, exposed Docker sockets, or vulnerable applications safe.
Best Practices
- Create a dedicated application user in the image and switch to it with
USERbeforeCMDorENTRYPOINT. - Use specific base image tags such as
node:20-alpine,python:3.12-alpine, oralpine:3.20;latestis a moving target. - Use
COPY --chownandchownduring the build so runtime directories are writable without root. - Prefer numeric UIDs and GIDs in shared Compose files when host and image account names may differ.
- Keep privileged setup early in the Dockerfile, then run the final application with least privilege.
- Do not publish or mount sensitive host paths just because the container is non-root.
- Use unprivileged ports inside the container, such as
8080, then publish the desired host port with-por Composeports. - Combine non-root users with other controls: minimal images, read-only filesystems where possible, dropped capabilities, secrets mounted as files, and resource limits.
- Test startup, logging, uploads, caches, and temporary files after switching users. Permission bugs often appear outside the happy path.
Practice Exercises
- Run
alpine:3.20twice: once normally and once with--user 12345:12345. Expected end state:idshows root in the first container and UID12345in the second. - Write a Dockerfile for a small app that creates an
appuser, owns/app, switches toUSER app, and runs a command that writes a file under/app. Hint: make the ownership change beforeUSER. - Create a Compose file for an Alpine service running as
10001:10001that writes one line to a named volume. Expected end state: the service completes successfully without running as root.
Summary
- Containers run as the image’s configured user unless
docker run --useror Composeuseroverrides it. - Root inside a container is still more privilege than most applications need, especially with mounts and kernel-facing features.
- Use Dockerfile
USERto make non-root the default, not an optional runtime habit. - Prepare writable directories and copied files with the right ownership before switching users.
- Non-root execution reduces risk, but it must be combined with careful mounts, secrets handling, capability limits, and patched images.
