TechEarl

Running Docker Containers as a Non-Root User

By default, processes inside Docker containers run as root, which is risky. Switch to a non-root USER, fix permissions on volumes and ports, and configure Compose and Kubernetes to refuse to run root containers.

Ishan KarunaratneIshan Karunaratne⏱️ 6 min readUpdated
Share thisCopied

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:

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

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

dockerfile
RUN groupadd --system app && useradd --system --gid app --shell /bin/false app

Same 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.

dockerfile
# 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 write

Fix with --chown on the COPY instruction:

dockerfile
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.

bash
docker run --rm -v "$(pwd):/work" my-app
# Error: cannot write to /work/output.json — permission denied

Options:

Match UIDs. Create the container user with the same UID as the host user:

dockerfile
ARG UID=1000
ARG GID=1000
RUN addgroup --gid $GID app && adduser --uid $UID --gid $GID --disabled-password app
USER app
bash
docker 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:

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

bash
docker run --rm -v my-volume:/data alpine chown -R 1000:1000 /data

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

code
bind: permission denied

Three fixes:

Run on a high port inside the container, map low port outside:

bash
docker run -p 80:8080 my-app   # host port 80, container port 8080

Inside 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):

dockerfile
RUN apk add --no-cache libcap && setcap 'cap_net_bind_service=+ep' /usr/local/bin/my-app
USER app

CAP_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

yaml
services:
  app:
    image: my-app
    user: "1000:1000"  # or "app"
    volumes:
      - ./data:/data

Same as docker run --user. Overrides the image's USER.

Enforce no-root containers

In Kubernetes:

yaml
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 1000
  containers:
    - name: app
      image: my-app

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

yaml
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 escalation

The 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

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

bash
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 app comes before installing packages with RUN 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-arg or 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:container mapping to bind the low port at the daemon level.
  • USER root snuck back in. Multi-stage builds where the final stage starts from a different base image can default back to root. Always explicitly USER app (or whatever) in the final stage.

What to do next

FAQ

Sources

Authoritative references this article was fact-checked against.

TagsDockerSecurityNon-RootUSERBest PracticesDevOps

Found this useful? Pass it on.

Copied
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

d

docker logs: View Container Output and Tail Logs

Read the stdout and stderr of a running or stopped container. Follow live output, tail the last N lines, filter by time, prepend timestamps, and the cases where docker logs doesn't help because the app writes to a file instead.

d

docker exec: Run Commands Inside a Running Container

Shell into a running container, run one-off commands, drop down to root when you need to install something, and the difference between an interactive session and a single command. With the alpine sh vs bash gotcha and -u for breaking out of non-root containers.