Skip to content
Go back

Running Docker Containers as Non-Root (And Why You Should)

By SumGuy 5 min read
Running Docker Containers as Non-Root (And Why You Should)

Your Docker container runs as root by default. Every container. This is convenient during development—no permission issues, everything works—but it’s a security disaster waiting to happen.

If someone breaks into your container, they get root. If the container escapes, they get root on your host. That’s game over.

Why This Is a Problem

Root in a container is nearly as dangerous as root on your host. A compromised container process can:

Even if the container itself is secure, running as root is “privilege you don’t need,” and that violates the principle of least privilege.

The Fix: Add a USER Instruction

The simplest fix: create a non-root user and switch to it before running your app.

Dockerfile
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y curl
# Bad: runs as root
ENTRYPOINT ["curl", "https://example.com"]

Better:

Dockerfile
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y curl
# Create a non-root user
RUN useradd -m -u 1000 appuser
# Switch to that user
USER appuser
ENTRYPOINT ["curl", "https://example.com"]

Now the container runs as appuser (UID 1000), not root. If it’s compromised, the attacker doesn’t have root privileges.

Verify:

Terminal window
$ docker build -t myapp .
$ docker run myapp id
uid=1000(appuser) gid=1000(appuser) groups=1000(appuser) # ✓ Not root

The Pattern: Use Numeric UIDs

For production, always use numeric UIDs/GIDs instead of usernames. Here’s why:

  1. Consistency across systems: UID 1000 is UID 1000 everywhere
  2. Reproducibility: Numeric IDs don’t depend on /etc/passwd
  3. Volume mount safety: When you mount volumes, permissions depend on UID/GID, not names

Here’s the production pattern:

Dockerfile
FROM node:18-slim
WORKDIR /app
# Install as root (you need privileges for this)
RUN apt-get update && apt-get install -y some-package
# Create a user with explicit numeric UID/GID
RUN groupadd -g 1001 appgroup && \
useradd -u 1001 -g 1001 -m appuser
# Copy files as root (ownership is root)
COPY package*.json ./
COPY src ./src
# Fix ownership
RUN chown -R appuser:appgroup /app
# Switch to non-root user
USER 1001
EXPOSE 3000
CMD ["node", "src/index.js"]

Why use 1001 instead of 1000? Avoid conflicts with common UIDs. 1000 is often the first user on a Linux system. Use 1001+ for apps.

The Volume Mount Gotcha

Here’s where numeric UIDs matter:

Terminal window
$ docker run -v /tmp/data:/app/data myapp
$ ls -l /tmp/data
drwxr-xr-x 2 appuser appuser 4096 Jan 22 10:00 /app/data

The container created /app/data as UID 1001. On the host, that shows as appuser (if UID 1001 exists on the host) or as the numeric UID. If you mount a volume that’s owned by UID 1000 on the host, and your container runs as UID 1001, you’ll get permission denied.

The fix: use numeric UIDs consistently, and understand the mapping:

Terminal window
# Container UID 1001 writes to host directory
# Host sees it as UID 1001 (numeric) or the matching user
# Make sure the mounted directory allows that UID to write
$ chown -R 1001:1001 /host/data
$ docker run -v /host/data:/app/data myapp

Multi-Stage Pattern (Production)

For production images, a common pattern is:

Dockerfile
# Stage 1: Builder (root, can install build tools)
FROM node:18 AS builder
WORKDIR /build
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Runtime (non-root, minimal)
FROM node:18-slim
WORKDIR /app
# Create user
RUN useradd -u 1001 -m appuser
# Copy built app from builder
COPY --from=builder /build/dist ./dist
COPY --from=builder /build/node_modules ./node_modules
COPY --from=builder /build/package*.json ./
# Fix ownership
RUN chown -R appuser:appuser /app
# Switch to non-root
USER 1001
EXPOSE 3000
CMD ["node", "dist/index.js"]

The builder stage can be root (needs compilers and tools). The runtime is minimal and runs as UID 1001.

Special Case: Scratch Images

If you’re using FROM scratch (a truly minimal base), there’s no userdb:

Dockerfile
FROM scratch
COPY myapp /myapp
ENTRYPOINT ["/myapp"]

You can’t create users in scratch. Options:

  1. Use a real base image (alpine, distroless) if possible
  2. Run as root (worst case, but sometimes necessary)
  3. Copy /etc/passwd from a builder stage:
Dockerfile
FROM alpine AS builder
RUN useradd -u 1001 appuser
FROM scratch
COPY --from=builder /etc/passwd /etc/passwd
COPY myapp /myapp
USER 1001
ENTRYPOINT ["/myapp"]

Distroless Images (The Easy Way)

Google’s distroless images come with a nonroot user pre-configured:

Dockerfile
FROM gcr.io/distroless/nodejs18-debian11
COPY app /app
WORKDIR /app
USER nonroot
ENTRYPOINT ["node", "index.js"]

No need to create a user. It’s already there. Distroless images are tiny, secure, and have no shell or package manager (harder to exploit).

Quick Checklist

Running as non-root is low-hanging fruit. It takes 5 minutes and makes your container significantly harder to exploit. Do it.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it may appear here.


Previous Post
zram vs Swap: What's Actually Faster for Low-RAM Servers
Next Post
strace for Beginners: See What Any Process Is Doing

Related Posts