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:
- Mount the host filesystem
- Access other containers’ volumes
- Modify system files
- Escalate to the host kernel (via exploits)
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.
FROM ubuntu:22.04RUN apt-get update && apt-get install -y curl
# Bad: runs as rootENTRYPOINT ["curl", "https://example.com"]Better:
FROM ubuntu:22.04RUN apt-get update && apt-get install -y curl
# Create a non-root userRUN useradd -m -u 1000 appuser
# Switch to that userUSER 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:
$ docker build -t myapp .$ docker run myapp iduid=1000(appuser) gid=1000(appuser) groups=1000(appuser) # ✓ Not rootThe Pattern: Use Numeric UIDs
For production, always use numeric UIDs/GIDs instead of usernames. Here’s why:
- Consistency across systems: UID 1000 is UID 1000 everywhere
- Reproducibility: Numeric IDs don’t depend on
/etc/passwd - Volume mount safety: When you mount volumes, permissions depend on UID/GID, not names
Here’s the production pattern:
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/GIDRUN 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 ownershipRUN chown -R appuser:appgroup /app
# Switch to non-root userUSER 1001
EXPOSE 3000CMD ["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:
$ docker run -v /tmp/data:/app/data myapp$ ls -l /tmp/datadrwxr-xr-x 2 appuser appuser 4096 Jan 22 10:00 /app/dataThe 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:
# 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 myappMulti-Stage Pattern (Production)
For production images, a common pattern is:
# Stage 1: Builder (root, can install build tools)FROM node:18 AS builderWORKDIR /buildCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build
# Stage 2: Runtime (non-root, minimal)FROM node:18-slimWORKDIR /app
# Create userRUN useradd -u 1001 -m appuser
# Copy built app from builderCOPY --from=builder /build/dist ./distCOPY --from=builder /build/node_modules ./node_modulesCOPY --from=builder /build/package*.json ./
# Fix ownershipRUN chown -R appuser:appuser /app
# Switch to non-rootUSER 1001
EXPOSE 3000CMD ["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:
FROM scratchCOPY myapp /myappENTRYPOINT ["/myapp"]You can’t create users in scratch. Options:
- Use a real base image (alpine, distroless) if possible
- Run as root (worst case, but sometimes necessary)
- Copy /etc/passwd from a builder stage:
FROM alpine AS builderRUN useradd -u 1001 appuser
FROM scratchCOPY --from=builder /etc/passwd /etc/passwdCOPY myapp /myappUSER 1001ENTRYPOINT ["/myapp"]Distroless Images (The Easy Way)
Google’s distroless images come with a nonroot user pre-configured:
FROM gcr.io/distroless/nodejs18-debian11COPY app /appWORKDIR /appUSER nonrootENTRYPOINT ["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
- All production containers have a
USERinstruction - User is non-root (UID >= 1000)
- Use numeric UID/GID for consistency
- Fix file ownership if needed (
RUN chown) - Test:
docker run myapp idshould show non-root - If using volumes, ensure UID/GID mappings are correct
- For production, prefer distroless or alpine images
Running as non-root is low-hanging fruit. It takes 5 minutes and makes your container significantly harder to exploit. Do it.