20.3.1.1 Non Root Hardening
A focused guide to Non Root Hardening, connecting core concepts with practical Docker and container operations.
Non-root hardening means configuring a Docker container to run its application process as a non-privileged user rather than root. By default, processes inside Docker containers run as root (UID 0). While container namespaces restrict what root inside the container can do compared to root on the host, it remains true that a root process inside the container has significantly more attack surface than a non-root process. Non-root hardening is the single most impactful security measure applicable to application containers and should be standard practice for all production workloads.
Why Root Inside Containers Is Risky
A root process inside a container, if compromised, can:
- Read and write any file that is bind-mounted from the host (if the mounted path is root-owned on the host).
- Write to named volumes used by the container.
- Exploit privilege escalation vulnerabilities in the Linux kernel, where certain CVEs are only exploitable by root.
- If the Docker socket is mounted (
/var/run/docker.sock), create new containers with full privileges and escape the container entirely.
Running as a non-root user eliminates or significantly reduces all of these attack vectors because the non-root process does not have the permissions needed to exploit them.
Creating a Non-Root User in the Dockerfile
Some official base images include a non-root user. The node:20-alpine image includes a node user (UID 1000). The python:3.12-slim image does not include a pre-created app user, so one must be created:
For images without a pre-existing user:
FROM python:3.12-slim
WORKDIR /app
# Create a non-root user and group
RUN groupadd -r appuser && useradd -r -g appuser appuser
# Install dependencies as root (write to /usr/local)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application files and change ownership
COPY --chown=appuser:appuser . .
# Switch to non-root user
USER appuser
CMD ["python", "app.py"]
For Node.js using the built-in node user:
FROM node:20-alpine
WORKDIR /app
COPY --chown=node:node package.json package-lock.json ./
RUN npm ci --only=production
COPY --chown=node:node . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]
The --chown=node:node flag on COPY sets file ownership at copy time. Without it, files are owned by root, and the node user cannot read them.
Understanding USER in Multi-Stage Builds
In a multi-stage build, each stage inherits the user context from its own FROM line, not from a previous stage. If the builder stage runs as root and the runtime stage runs as a non-root user, both must be configured correctly:
FROM node:20-alpine AS builder
WORKDIR /app
# builder runs as root to install all deps and compile
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
# Copy files with correct ownership for the runtime user
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
The runtime stage runs as node. Files copied from the builder stage use --chown=node:node so the runtime user can access them.
Verifying the Running User
docker build -t my-app .
docker run --rm my-app id
Expected output:
uid=1000(node) gid=1000(node) groups=1000(node)
If uid=0(root) appears, the USER instruction is missing, was placed before a RUN that resets to root, or the base image does not have the expected user.
docker run --rm my-app whoami
node
Overriding the User at Runtime
If the Dockerfile sets a non-root user but the container must run as root for a specific task (such as debugging), the user can be overridden at runtime:
docker run --user root -it my-app sh
This should only be done for debugging purposes. Production deployments should never override the Dockerfile's USER instruction to run as root.
Common Problem: Write Permission Denied
A non-root user cannot write to directories owned by root. This manifests as errors like EACCES: permission denied or PermissionError: [Errno 13] when the application attempts to write to a path it was not given ownership of.
Solution 1: Set ownership in the Dockerfile using --chown:
COPY --chown=appuser:appuser . .
Solution 2: Use chown in a RUN instruction:
RUN chown -R appuser:appuser /app/data
Solution 3: Use writable volumes or tmpfs mounts for paths the application must write to:
docker run -d \
--user 1000:1000 \
--tmpfs /tmp \
-v app_data:/app/data \
my-app
The application writes temporary files to /tmp (in-memory tmpfs) and persistent data to the named volume /app/data, both of which are writable by the non-root user.
Alpine Linux and adduser
Alpine uses BusyBox's adduser, not the standard useradd:
FROM alpine:3.19
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --chown=appuser:appgroup . .
USER appuser
CMD ["./my-binary"]
-S creates a system user/group (no shell, no home directory), which is appropriate for container processes that do not need an interactive user account.
Rootless Docker
Rootless Docker runs the Docker daemon itself as a non-root user on the host. Even without rootless mode, running container processes as non-root provides defense in depth — if an attacker escapes the container namespace, they arrive on the host as a non-privileged user rather than as root.
Rootless Docker provides an additional layer of isolation for environments where even the Docker daemon having root access is unacceptable, such as shared multi-tenant compute nodes.
Impact on Port Binding
Non-root users cannot bind to ports below 1024 without special configuration. An application running as a non-root user that tries to listen on port 80 will fail with "permission denied."
The correct approach is to have the application listen on a high port (3000, 8080, 8443) and use Docker's port publishing to map it to port 80 or 443 on the host:
docker run -p 80:3000 my-app
The host's port 80 is forwarded to the container's port 3000. The container process runs as a non-root user listening on port 3000 — no special permissions needed.
To allow a non-root user to bind a low port inside the container, add the NET_BIND_SERVICE capability:
docker run --cap-add NET_BIND_SERVICE --user 1000 my-app
This is rarely necessary if the application is designed to listen on high ports.