Add Users to a Docker Container: Non-Root User Guide
Learn how to add users to a Docker container the secure way: Dockerfile USER, UID/GID pinning, docker exec -u, Compose user & group_add, and permission fixes.

Docker containers run as root by default. Fine for a quick test, a liability in production. This guide shows you how to add users to a Docker container the right way: bake non-root users into your image, pin UID/GID, fix volume permissions, and verify before you deploy.
Three approaches, in order of preference:
- Dockerfile: create users at build time (recommended for anything that needs to survive a restart)
- Runtime overrides:
docker run -uanddocker exec -u(debugging and one-off tasks) - UID/GID mapping: match container and host users to fix bind-mount permission headaches
This guide covers Ubuntu 24.04 and Alpine 3.24, Docker Compose V2, and Docker Engine 29.x. If you need a Linux VPS to practice on, you can start with a Hetzner VPS or Hostinger VPS.
VPS prices jumped across the board in 2026 — if you’re rethinking a rented box, see what changed and when a mini PC wins.
Why Run Containers as a Non-Root User?
Running as root in containers creates three problems:
- Security: If the application gets compromised, the attacker has root inside the container. On misconfigured hosts (especially with
--privilegedor writable bind mounts), that can escalate to root on the host. - Permissions: A root process writing to a bind-mounted volume creates root-owned files on the host. Your host user (UID 1000) can’t edit or delete them without
sudo. - Least privilege: Same principle that applies everywhere else. Production containers should not run as root.
The Bind-Mount Trap
This is the #1 problem self-hosters hit. A container running as root writes to a mounted volume, and suddenly every file on the host is owned by root:root. You end up running sudo chown on the host after every container restart. The fix: pin the container’s UID to match your host user (usually 1000). We cover this in Method 3 below.
Know Your Base Image
Before creating a user, check whether the image already ships one. Some official images include a non-root user out of the box:
docker run --rm node id
# uid=1000(node) gid=1000(node) groups=1000(node)
| Image | Default non-root user | UID |
|---|---|---|
node |
node |
1000 |
postgres |
postgres (via gosu in entrypoint) |
999 |
nginx |
nginx (worker processes) |
101 |
ubuntu / debian / alpine |
None, you must create one | - |
Check Before You Create
Run docker run --rm <image> id to see if the image already has a non-root user. If it does, just use USER <name>. Don’t create a second one that might conflict.
Method 1: Add Users in the Dockerfile (Recommended)
Building users into the image is the durable approach. Users belong to images; containers inherit them. This survives restarts, redeployments, and docker compose up cycles.
Create a Non-Root User with useradd
The hardened pattern uses --no-log-init, pinned UID/GID, and a non-login shell:
FROM ubuntu:24.04
# Create a dedicated app user with a pinned UID/GID.
# 1001 leaves 1000 free to match the host user in bind mounts.
RUN groupadd -g 1001 appgroup && \
useradd --no-log-init -u 1001 -g appgroup -m -s /usr/sbin/nologin appuser
# Run the container as the unprivileged user (numeric UID: survives /etc/passwd changes)
USER 1001:1001
WORKDIR /home/appuser
Flag breakdown:
| Flag | What it does |
|---|---|
--no-log-init |
Avoids the faillog sparse-file disk-exhaustion bug with large UIDs (Go archive/tar issue) |
-u 1001 |
Pins the UID, deterministic across rebuilds |
-g appgroup |
Sets primary group (must exist first, that’s why groupadd comes before useradd) |
-m |
Creates the home directory |
-s /usr/sbin/nologin |
Non-login shell for service accounts; use /bin/bash only if you need interactive access |
Why --no-log-init?
Without this flag, useradd writes to /var/log/faillog and /var/log/lastlog. With a large UID, these files can balloon to gigabytes of NULL bytes due to a sparse-file bug in Go’s archive/tar. Always pass --no-log-init in Dockerfiles.
You can parameterize UID/GID via build args. See ARG and ENV in Docker for the full pattern.
The Alpine Variant: adduser and addgroup
Alpine uses busybox adduser/addgroup, not the shadow-utils useradd. The flags are different:
FROM ubuntu:24.04
RUN groupadd -g 1001 appgroup && \
useradd --no-log-init -u 1001 -g appgroup -m -s /usr/sbin/nologin appuser
USER 1001:1001
WORKDIR /home/appuserFROM alpine:3.24
RUN addgroup -g 1001 appgroup && \
adduser -D -u 1001 -G appgroup -s /bin/sh appuser
USER 1001:1001
WORKDIR /home/appuserAlpine flag differences: -D = no password (equivalent to --disabled-password), -G = primary group, -s /bin/sh = shell (Alpine doesn’t have bash by default).
Lock It Down With Numeric USER 1001:1001
Docker’s own guidance recommends numeric UIDs:
# Good: survives /etc/passwd changes, no name resolution needed
USER 1001:1001
# Works but fragile: depends on /etc/passwd entry existing
USER appuser
When you specify a group (the :1001 part), the user gets only that group. No supplementary groups from /etc/group. This is usually what you want for a service account.
Set File Ownership with COPY --chown
Use COPY --chown to set ownership at build time instead of running chown in a separate RUN layer:
COPY --chown=1001:1001 app/ /home/appuser/app/
USER 1001:1001
COPY --chown accepts names or numeric UID/GID. Names require /etc/passwd resolution during the build. If the user doesn’t exist yet at that layer, the build fails. Numeric UIDs are safer and more portable.
For keeping Dockerfile layers lean, see copying multiple files in a single Dockerfile layer.
Multi-Stage Builds: Build as Root, Run as User
Multi-stage builds let you install packages and compile as root in a build stage, then copy only the artifacts into a clean runtime stage running as a non-root user:
# Build stage: runs as root, installs dependencies
FROM ubuntu:24.04 AS builder
RUN apt-get update && apt-get install -y --no-install-recommends build-essential
COPY . /src
WORKDIR /src
RUN make build
# Runtime stage: minimal image, non-root user
FROM ubuntu:24.04
RUN groupadd -g 1001 appgroup && \
useradd --no-log-init -u 1001 -g appgroup -m -s /usr/sbin/nologin appuser
COPY --chown=1001:1001 --from=builder /src/app /home/appuser/app
USER 1001:1001
WORKDIR /home/appuser
CMD ["./app"]
The runtime image never has build-essential, source code, or build artifacts. Smaller attack surface, smaller image.
Method 2: Add Users at Runtime (Debugging Only)
Runtime user changes are temporary and useful only for debugging or one-off tasks. They live in the writable container layer and are lost when the container is recreated.
Runtime Changes Are Ephemeral
Any users or permission changes made inside a running container are lost when it’s recreated (docker compose up, docker rm). Bake users into the image for anything that needs to survive a restart. See updating containers with Docker Compose for how redeployments wipe the writable layer.
docker run -u / --user and --group-add
The -u / --user flag overrides the container’s default user at runtime. Numeric IDs don’t require an /etc/passwd entry:
# Run as UID 1001 with GID 1001
docker run --rm -it -u 1001:1001 myimage sh
# Add supplementary group 2000 (e.g., a shared volume group)
docker run --rm -it --user 1001:1001 --group-add 2000 myimage id
# Accepted formats:
# -u user # looked up in /etc/passwd
# -u user:group # both looked up
# -u uid # numeric, no lookup needed
# -u uid:gid # numeric, no lookup needed
# -u user:gid # mixed
# -u uid:group # mixed
This is useful for testing whether your app works as a non-root user before committing the change to the Dockerfile.
Inspect as Another User With docker exec -u
Instead of modifying a running container, use docker exec -u to run commands as a different user:
# Open a shell as appuser inside a running container
docker exec -it -u appuser mycontainer sh
# Run a single command as a specific UID
docker exec -u 1001:1001 mycontainer whoami
# appuser
# Check what files the app user can access
docker exec -u 1001:1001 mycontainer ls -la /app
This doesn’t modify the container — you’re just running a process as a different user. Great for diagnosing permission issues. For debugging container logs, see redirecting Docker logs to a single file.
Method 3: Map Host UID/GID to Fix Volume Permissions
This is the section that solves the bind-mount ownership problem from the intro. The core issue: container UID 0 (root) writes files to a mounted volume, and the host sees root-owned files that your host user (UID 1000) can’t edit.
The fix: pin the app UID to match the host user.
Build-Arg UID/GID Mapping
Pass the host user’s UID/GID at build time:
FROM ubuntu:24.04
ARG USER_ID=1000
ARG GROUP_ID=1000
RUN groupadd -g "$GROUP_ID" appgroup && \
useradd --no-log-init -u "$USER_ID" -g "$GROUP_ID" -m -s /usr/sbin/nologin appuser
USER "$USER_ID:$GROUP_ID"
Build with your host user’s IDs:
docker build --build-arg USER_ID=$(id -u) --build-arg GROUP_ID=$(id -g) -t myapp .
The default of 1000 matches the standard first user on most Linux systems. On a single-user VPS, this is almost always correct. For more on build args, see ARG and ENV in Docker.
Docker Compose: user, group_add, and PUID/PGID
The Compose spec supports user and group_add directly. No version: key needed (it’s obsolete in Compose V2), and the command is docker compose, not docker-compose:
services:
app:
build: .
user: "1001:1001"
group_add:
- "2000"
volumes:
- .:/app
environment:
- USER_ID=${USER_ID:-1001}
- GROUP_ID=${GROUP_ID:-1001}
docker compose up -d --build
For a single-user VPS, hard-code user: "1000:1000" to match the ubuntu deploy user. The ${UID}/${GID} shell-export trick that some tutorials recommend breaks on macOS and Docker Desktop where the VM’s UIDs differ.
PUID/PGID Convention
Many popular self-hosted images (LinuxServer.io containers like Plex, Sonarr, Jellyfin) use PUID and PGID environment variables to set the runtime user at startup. The entrypoint script reads these values and creates/switches to the matching user. This is a convention, not a Docker feature — check your image’s docs. If you’re running a Python app in Docker Compose as a non-root user, the user: field is the cleaner approach.
If you’re setting up Docker for the first time, see installing Docker on Ubuntu ARM.
Privilege Escalation Done Right: gosu Instead of sudo
The old advice was to install sudo in your image and add users to the sudo group. Docker’s own best practices now say to avoid this:
Avoid installing or using sudo as it has unpredictable TTY and signal-forwarding behavior. Instead, use gosu. — Docker build best practices
The gosu pattern (same as the official Postgres image) starts the entrypoint as root, fixes ownership of data directories, then drops to the app user:
FROM ubuntu:24.04
RUN apt-get update && \
apt-get install -y --no-install-recommends gosu && \
rm -rf /var/lib/apt/lists/*
RUN groupadd -g 1001 appgroup && \
useradd --no-log-init -u 1001 -g appgroup -m appuser
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["docker-entrypoint.sh"]
#!/bin/sh
set -e
# If running as root, fix ownership and drop to appuser
if [ "$(id -u)" = "0" ]; then
chown -R appuser:appgroup /data
exec gosu appuser "$@"
fi
exec "$@"
The container starts as root (so it can chown the data volume), then exec gosu appuser replaces the process with the unprivileged user. The app never runs as root.
Never Bake Secrets Into Image Layers
Passwords and keys in RUN commands are visible via docker history. The old pattern of echo 'appuser:password' | chpasswd puts a plaintext password in the image forever. Use runtime environment variables or securing secrets in Docker Compose instead.
Advanced Hardening: Userns-Remap and Rootless Docker
The USER instruction handles most cases. If you need stronger isolation (multi-tenant hosts, untrusted images, compliance requirements), two options exist:
User-Namespace Remapping
Maps container root (UID 0) to an unprivileged host UID. Add to /etc/docker/daemon.json:
{ "userns-remap": "default" }
sudo systemctl restart docker
id dockremap # daemon-created remap user
grep dockremap /etc/subuid /etc/subgid # verify 65536-range subordinate IDs
Caveats:
- Enable on a fresh install only — it masks existing images and containers. You’ll need to re-pull everything.
- Per-container opt-out:
docker run --userns=host(but this has side effects on file ownership). --privilegedrequires--userns=host;mknodis denied in user namespaces.- Bind-mount ownership must be pre-arranged — the remapped UID on the host won’t match your expectations unless you plan for it.
Docker 29 + Userns-Remap
On Docker 29.x fresh installs, the containerd image store is the default and is temporarily incompatible with userns-remap (moby/moby#47377). Enabling remap switches the daemon back to the legacy image store. Verify behavior on your exact Docker version before enabling in production.
Rootless Mode
Runs the Docker daemon itself as an unprivileged user. Prerequisites: newuidmap/newgidmap and at least 65,536 subordinate UIDs in /etc/subuid and /etc/subgid.
# Install the rootless setup tool
dockerd-rootless-setuptool.sh install
# Start the rootless daemon
systemctl --user start docker
Docker 29.5.0 made gvisor-tap-vsock the default rootless network driver, which improves compatibility.
When to bother: If you’re running untrusted images, hosting for multiple users, or have compliance requirements. For a single-user VPS running your own images, USER in the Dockerfile is enough. For more on Docker security, see hardening Docker on a Hetzner VPS.
Verify Before You Deploy
Don’t assume — verify. Run these commands after building and after every deploy:
docker run --rm myimage id— expectuid=1001(appuser) gid=1001(appgroup)docker inspect --format '{{.Config.User}}' myimage— expect1001:1001docker exec -it mycontainer id— confirm inside a running containerdocker compose exec app id— Compose equivalentdocker exec mycontainer touch /app/check.txt && ls -l ./check.txt— expect your UID, not rootdocker build --check .— Dockerfile linter catches missingUSER(Dockerfile v1.8.0+)
Quick verification script:
# After build — confirm the image's default user
docker run --rm myimage id
# Inspect without running
docker create --name tmp myimage && docker inspect --format '{{.Config.User}}' tmp && docker rm tmp
# Inside a running container
docker exec -it mycontainer id
# Bind-mount ownership check (host side)
docker exec mycontainer touch /app/check.txt && ls -l ./check.txt
# Should show your host UID (e.g., 1000), not root
Troubleshooting: Common Permission Failure Modes
| Symptom | Cause | Fix |
|---|---|---|
| Files in bind mount owned by root | Container ran as root | Pin app UID to host UID (user: "1000:1000") or one-time sudo chown -R $(id -u):$(id -g) ./data |
COPY --chown=name fails at build |
Name not in /etc/passwd yet at that layer |
Use numeric UID: COPY --chown=1001:1001 |
| Port < 1024 denied for non-root | Non-root can’t bind privileged ports | --cap-add NET_BIND_SERVICE or use a high port |
Users vanished after docker compose up |
Runtime changes are ephemeral | Bake user into the Dockerfile |
| Entrypoint needs root to chown volumes | App user can’t fix ownership at startup | Use gosu entrypoint pattern or Compose pre_start hook with user: root |
userns-remap hides existing images |
Remap masks the image/container store | Enable on fresh hosts only; re-pull images |
| macOS host UID doesn’t match container | Docker Desktop VM UIDs differ | Test on a Linux VPS; hard-code UID for production |
Why are my files owned by root after Docker writes to them?
When a container runs as root (the default) and writes to a bind-mounted volume, the files are created with UID 0 on the host. Your host user (typically UID 1000) can’t edit or delete them without sudo.
Fix in the image: Set USER 1000:1000 in your Dockerfile (or user: "1000:1000" in Compose) so the process runs as your host user.
Fix after the fact: Run sudo chown -R $(id -u):$(id -g) ./data on the host. To prevent it from happening again, pin the UID in your Dockerfile.
Why does COPY --chown fail with a username?
COPY --chown=name:group resolves the name against /etc/passwd and /etc/group during the build. If the user hasn’t been created yet at that point in the Dockerfile (or the file doesn’t exist in the build stage), the build fails.
Fix: Use numeric UIDs instead: COPY --chown=1001:1001. This works regardless of whether /etc/passwd has the entry.
Why can't my non-root container bind port 80?
On Linux, ports below 1024 are privileged — only root (or processes with CAP_NET_BIND_SERVICE) can bind them. This is why the official Nginx image keeps a root master process.
Fix options:
--cap-add NET_BIND_SERVICEon the container (grants just the bind capability)- Use a high port (8080, 8443) and map it in Compose:
ports: ["8080:80"] - Use a reverse proxy (Caddy, Traefik) that handles port 80/443 and forwards to your app’s high port
For more Docker commands and patterns, bookmark the essential Docker commands reference.
Quick Reference: User Creation Commands
| Task | Ubuntu/Debian | Alpine |
|---|---|---|
| Create user with home dir | useradd --no-log-init -m -s /bin/bash user |
adduser -D -s /bin/sh user |
| Create group | groupadd -g 1001 appgroup |
addgroup -g 1001 appgroup |
| Create user with specific UID/GID | useradd --no-log-init -u 1001 -g appgroup -m user |
adduser -D -u 1001 -G appgroup user |
| Add user to group | usermod -aG sudo user |
addgroup user sudo |
| Set Dockerfile default user | USER 1001:1001 |
USER 1001:1001 |
| Run as specific user | docker run -u 1001:1001 img |
Same |
| Exec as specific user | docker exec -u user container cmd |
Same |
| Compose user override | user: "1001:1001" |
Same |
| Compose supplementary groups | group_add: ["2000"] |
Same |
| Copy with ownership | COPY --chown=1001:1001 src/ dest/ |
Same |
TL;DR
- Always create non-root users for production containers —
USER 1001:1001in the Dockerfile - Pin UID/GID (e.g., 1001:1001) to avoid permission headaches with bind-mounted volumes
- Use
COPY --chown=1001:1001with numeric UIDs — names can fail at build time - Verify with
docker run --rm myimage idandls -lon bind mounts before deploying - Use gosu (not sudo) when your entrypoint needs root at startup to fix volume ownership
Some other Docker articles that can help you in your Docker journey:


