Bitdoze Logo

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.

DragosDragos23 min read
Add Users to a Docker Container: Non-Root User Guide

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:

  1. Dockerfile: create users at build time (recommended for anything that needs to survive a restart)
  2. Runtime overrides: docker run -u and docker exec -u (debugging and one-off tasks)
  3. 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 --privileged or 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.

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:

Alpine 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).
  • --privileged requires --userns=host; mknod is 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 — expect uid=1001(appuser) gid=1001(appgroup)
  • docker inspect --format '{{.Config.User}}' myimage — expect 1001:1001
  • docker exec -it mycontainer id — confirm inside a running container
  • docker compose exec app id — Compose equivalent
  • docker exec mycontainer touch /app/check.txt && ls -l ./check.txt — expect your UID, not root
  • docker build --check . — Dockerfile linter catches missing USER (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:

  1. --cap-add NET_BIND_SERVICE on the container (grants just the bind capability)
  2. Use a high port (8080, 8443) and map it in Compose: ports: ["8080:80"]
  3. 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:1001 in the Dockerfile
  • Pin UID/GID (e.g., 1001:1001) to avoid permission headaches with bind-mounted volumes
  • Use COPY --chown=1001:1001 with numeric UIDs — names can fail at build time
  • Verify with docker run --rm myimage id and ls -l on 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: