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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

When It is Okay to Leave a Meeting Without Asking Permission

Sometimes, sitting through a meeting feels like watching paint dry. Not every minute in a calendar invite deserves your attention—and that’s okay.

Read more

What Microservices Actually Mean and Why Most Teams Get It Wrong

Microservices are not small services or REST APIs with separate databases — they are a specific organizational and technical pattern with precise prerequisites. Most teams adopt the aesthetics without the substance.

Read more

Caching Docker Layers in CI/CD to Stop Waiting Forever

CI pipelines rebuild Docker images from scratch because they start with a clean environment every run. Registry-based layer caching gives your pipeline the same cache hits you get locally, often cutting build time by 70% or more.

Read more

How I Use Form Objects to Keep Rails Controllers Clean

Multi-model forms, complex validation logic, and params that don't map cleanly to database columns are where Rails' built-in form handling breaks down. Form objects fix all three without pulling in a framework.

Read more