By default, the process inside a Docker container runs as root (UID 0). That's root inside the container, not the host — but the isolation is thinner than people assume. A container escape, a misconfigured volume, a --privileged flag, a shared user namespace, or a CVE in the runtime can let root in the container become root on the host. Running as a non-root user shrinks the blast radius significantly.
Most official images now ship with a non-root user pre-built (nginx has nginx, postgres has postgres, node has node, etc.) but the default USER is still root unless you explicitly switch.
Add a USER to your Dockerfile
The simplest version, when you're building your own image:
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
USER node
CMD ["node", "server.js"]node:22-alpine pre-creates a node user (UID 1000). USER node switches every subsequent layer and the runtime process to that user.
For images without a pre-built user:
FROM alpine:3.20
RUN addgroup -S app && adduser -S -G app app
WORKDIR /app
COPY . .
RUN chown -R app:app /app
USER app
CMD ["./my-app"]-S makes a system account (no password, no home dir). chown makes sure the app user owns files it needs to read.
For Debian-based images, the syntax differs slightly:
RUN groupadd --system app && useradd --system --gid app --shell /bin/false appSame idea.
Fix file permissions
When you COPY files into an image as root and then switch USER, the files are still owned by root. The new user might be unable to read or write them.
# COPYing as root, then switching user — files still owned by root
COPY . /app
USER app
# Inside the container: ls -la /app shows root:root, app user can't writeFix with --chown on the COPY instruction:
COPY --chown=app:app . /app
USER app--chown works on ADD and COPY. It only sets ownership at copy time, so subsequent files written by the container at runtime are owned by the running user (app), which is what you want.
Bind mounts and volumes: UID alignment
This is where non-root containers get messy. Bind mounts use host file ownership, not container ownership. If your host files are owned by UID 1000 on the host and the container's app user is UID 1000 inside, they line up. If not, you get permission denied.
docker run --rm -v "$(pwd):/work" my-app
# Error: cannot write to /work/output.json — permission deniedOptions:
Match UIDs. Create the container user with the same UID as the host user:
ARG UID=1000
ARG GID=1000
RUN addgroup --gid $GID app && adduser --uid $UID --gid $GID --disabled-password app
USER appdocker build --build-arg UID=$(id -u) --build-arg GID=$(id -g) -t my-app .Now the container's app user has the same UID as your host user. Files line up.
Or run with --user at runtime:
docker run --rm --user $(id -u):$(id -g) -v "$(pwd):/work" my-app--user overrides the Dockerfile's USER for that container only. The process runs as your host UID; any files it creates are owned by you.
Or change ownership of the volume:
docker run --rm -v my-volume:/data alpine chown -R 1000:1000 /dataOne-time fixup. Works for named volumes (which start empty and get populated by the container).
Privileged ports
Linux reserves ports below 1024 for root. A non-root container can't bind port 80 or 443 directly:
bind: permission denied
Three fixes:
Run on a high port inside the container, map low port outside:
docker run -p 80:8080 my-app # host port 80, container port 8080Inside the container, the app listens on 8080 (no privilege needed). Docker's port forwarding maps host port 80 to container port 8080. The host port mapping happens at the Docker daemon level, which IS root.
This is the standard pattern. Use it.
Or grant the capability (less common):
RUN apk add --no-cache libcap && setcap 'cap_net_bind_service=+ep' /usr/local/bin/my-app
USER appCAP_NET_BIND_SERVICE lets the binary bind low ports without being root. Works for static binaries; not always trivial to set up for runtime-loaded binaries.
Or use net.ipv4.ip_unprivileged_port_start (Linux kernel option):
Configure the host or container kernel to make port 80 non-privileged. Not portable. Avoid.
Compose: setting user
services:
app:
image: my-app
user: "1000:1000" # or "app"
volumes:
- ./data:/dataSame as docker run --user. Overrides the image's USER.
Enforce no-root containers
In Kubernetes:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
containers:
- name: app
image: my-apprunAsNonRoot: true makes Kubernetes refuse to start the pod if the image runs as UID 0. Catches images that didn't switch USER.
Combine with Pod Security Standards' restricted profile to enforce non-root, no privileged, no host networking — across the whole namespace.
In Docker Compose, you can require it per-service:
services:
app:
image: my-app
user: "1000:1000"
read_only: true # immutable filesystem
cap_drop:
- ALL # drop all Linux capabilities
security_opt:
- no-new-privileges:true # prevent setuid escalationThe Compose specification ignores most of these for non-Swarm deployments, but they're useful defense-in-depth where supported.
Verify what user the container is actually running as
docker exec CONTAINER_NAME whoami
# app
docker exec CONTAINER_NAME id
# uid=1000(app) gid=1000(app) groups=1000(app)Or look inside running containers:
docker inspect --format '{{.Config.User}}' CONTAINER_NAME
# 1000:1000
ps -ef | grep -v root | grep CONTAINER
# Outside the container: see the actual host UID running the process.Common pitfalls
- Switching USER too early in the Dockerfile. If
USER appcomes before installing packages withRUN apt-get install, the install fails (not root). Switch user after all root-needing build steps. - Bind mount permission errors. Volume ownership mismatch. Match UIDs at build time with
--build-argor fix at runtime with--user $(id -u):$(id -g). - Image expects to write somewhere it doesn't own. Some images have configurable paths via env vars; others (older PHP, postgres, mysql) handle UID alignment internally. Check the image's docs.
- Trying to bind low ports as non-root. Use Docker's
-p host:containermapping to bind the low port at the daemon level. USER rootsnuck back in. Multi-stage builds where the final stage starts from a different base image can default back to root. Always explicitlyUSER app(or whatever) in the final stage.
What to do next
- How to Write a Dockerfile — where USER fits in the standard structure.
- How to Dockerize a Node.js App — uses
USER nodeend-to-end. - Docker Volumes vs Bind Mounts — context for the UID alignment problem.
FAQ
Sources
Authoritative references this article was fact-checked against.
- Dockerfile best practices — USERdocs.docker.com
- Pod Security Standards — Kuberneteskubernetes.io

