Stop Running Your App as Root Inside Docker
by Arif Ikhsanudin, Backend Developer
The default that nobody changes
Run docker exec -it your-container whoami on your production containers right now. If the answer is root, you're in the majority — and in a security posture that's worse than it needs to be.
Docker containers are not VMs. The process inside a container and the process running the Docker daemon share the same kernel. The namespace and cgroup isolation Docker provides prevents the container process from seeing other containers and limits what resources it can access — but a root process inside a container has capabilities that a non-root process doesn't. In default configurations, breaking out of a container as root is a known attack category. Breaking out as a non-root user with no capabilities is substantially harder.
The fix is three to five lines in your Dockerfile. Most teams don't do it because they haven't been burned yet.
What root inside a container actually means
By default, Docker containers run with a set of Linux capabilities (defined in the Open Container Initiative runtime spec). Root inside a container has all of these, including CAP_NET_ADMIN, CAP_SYS_PTRACE, and others that allow network configuration, process inspection, and more.
Specific risks:
- Volume mounts: If you mount a host directory into the container and the process runs as root, it can read and write everything in that mount with root privileges on the host filesystem.
- Privileged mode: If anyone has run or runs the container with
--privileged(more common in dev/test environments than you'd like), root inside is essentially root on the host. - Container escape vulnerabilities: Historical Docker vulnerabilities (runc CVE-2019-5736, for example) allowed container breakout. Exploitation is significantly easier when the container process is root.
- Lateral movement in orchestrated environments: In Kubernetes, a compromised container running as root has better access to host paths, service account tokens in
/var/run/secrets, and kubelet APIs.
Running as non-root doesn't prevent all of these, but it raises the bar. Defense in depth requires that this layer not be absent.
Adding a non-root user: the patterns
Alpine-based images (node:alpine, python:alpine, etc.):
Most Alpine-based official images already include a service user. node:alpine has a node user at UID 1000.
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY dist/ ./dist/
RUN chown -R node:node /app
USER node
ENTRYPOINT ["node", "dist/index.js"]
The chown before USER node is important — you need to set ownership while still running as root, or the node user won't have the right permissions on files copied in by earlier instructions.
Debian/Ubuntu-based images — create a user explicitly:
FROM eclipse-temurin:17-jre-jammy
RUN groupadd --gid 1001 appgroup \
&& useradd --uid 1001 --gid appgroup --shell /bin/sh --create-home appuser
WORKDIR /app
COPY --from=build /app/target/app.jar app.jar
RUN chown appuser:appgroup app.jar
USER appuser
ENTRYPOINT ["java", "-jar", "app.jar"]
Fixed GID/UID values (1001 in this case) are useful when volume mounts need predictable ownership on the host.
Distroless images (gcr.io/distroless/java17, etc.):
Distroless images from Google have a nonroot user baked in:
FROM gcr.io/distroless/java17-debian12:nonroot
COPY --from=build /app/target/app.jar /app/app.jar
CMD ["/app/app.jar"]
The :nonroot tag automatically sets the user to UID 65532. Distroless images contain only the runtime — no shell, no package manager, no utilities. The attack surface is minimal.
The permissions problems you'll hit
Running as non-root breaks things that were implicitly relying on root access. Common failures:
Writing to directories: If your app writes logs, creates temp files, or generates caches inside the container filesystem, those directories must be owned by or writable by the app user.
RUN mkdir -p /app/logs /app/tmp \
&& chown -R appuser:appgroup /app/logs /app/tmp
Binding to ports below 1024:
Ports below 1024 (including 80 and 443) require CAP_NET_BIND_SERVICE or root. A non-root process can't bind port 80 by default. The solution is to run your application on a high port (8080, 8443) and let the orchestrator or load balancer handle external port mapping. If you must bind a low port, grant the specific capability:
RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/myapp
But prefer the high-port approach — it's simpler and requires no capability grants.
Package installation at runtime: If your application tries to install packages or modify system files at runtime (which it shouldn't, but some bootstrapping scripts do), non-root will break this. Fix the bootstrapping — don't grant root access as a workaround.
Verifying non-root in your running container
docker run --rm your-image whoami
# should output: node (or appuser, or nonroot)
docker run --rm your-image id
# uid=1000(node) gid=1000(node) groups=1000(node)
In Kubernetes, also check that your Pod spec doesn't override the user:
securityContext:
runAsNonRoot: true
runAsUser: 1001
Setting runAsNonRoot: true at the pod level tells the kubelet to reject the pod if the image's configured user is root. This is a safety net — it won't fix a Dockerfile that uses USER root, but it will catch it at admission rather than silently running as root.
Dropping capabilities
Even as a non-root user, your container may have Linux capabilities you don't need. Add to your Compose or Kubernetes config:
# Docker Compose
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
# Kubernetes securityContext
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
no-new-privileges prevents the process from gaining new capabilities through setuid binaries. cap_drop: ALL removes all capabilities from the container. If your application needs a specific capability, add it back explicitly — but most backend applications need none.
The one-liner for your security review
Check your production Dockerfiles:
grep -n "^USER" Dockerfile
If this returns nothing, or returns USER root, you have work to do. The change is low-risk and takes under an hour to implement and verify. It's the kind of hardening that looks obvious in a post-incident review — better to do it before the incident.