Run Docker as Non-Root with a Read-Only Rootfs
Run containers as a non-root user on a read-only root filesystem with tmpfs for scratch space, and cut a wide slice out of container escape risk.
Netbay Infrastructure Team
Netbay Engineering
On this page
Two hardening moves shrink a container's attack surface more cheaply than any scanner: stop running its processes as root, and stop granting write access to its own filesystem. Both are one-line changes that sound trivial and are frequently skipped, which is why they keep showing up in penetration-test findings. A non-root user plus a read-only root filesystem means even a fully compromised application process cannot overwrite its own binaries, cannot plant a persistent backdoor in the image, and cannot touch anything on the host that the kernel does not explicitly allow. This guide assembles the combination — Dockerfile, compose file, and the tmpfs escape hatch for legitimate runtime writes — in a way you can reproduce on any VPS.
Why Root Inside the Container Is the Problem
It is a common and dangerous misconception that the container is isolated, so root inside it is fine. Container isolation is a kernel containment mechanism, not a magic wall: a root process inside the container shares the host's kernel, and when a kernel or runtime bug opens a gap, a root process has far more to work with than an unprivileged one. Non-root inside the container does not prevent container escape by itself, but it dramatically raises the bar for what an escaped process can do. A script that downloads a shell to /tmp and pokes at the host is running as your app user, not as UID 0 on the host.
A Dockerfile That Starts Correctly
FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates && \
rm -rf /var/lib/apt/lists/*
# Create an app identity once, at build time
RUN groupadd --system app && useradd --system --gid app --no-create-home app
COPY --chown=app:app dist/ /app/
WORKDIR /app
USER app
EXPOSE 8080
ENTRYPOINT ["/app/server"]The ordering is deliberate: files are copied with ownership set to app at copy time, so no chown at runtime, and no root privilege, is ever needed. If the application must write state, pick explicit directories at build time — for example /app/state — and let app own exactly those, so the read-only filesystem never has to bend for growth.
Enabling the Read-Only Root Filesystem
The runtime flag is read_only in Compose or --read-only on docker run. It mounts the container's root filesystem read-only after boot, so processes cannot modify image files. The classic consequence is that anything expecting to write under the root path — temp files, sockets, pid files — starts failing. That is not a reason to abandon the flag; it is a reason to give those writes an explicit home with tmpfs, a RAM-backed scratch space that vanishes when the container stops.
name: app
services:
api:
image: my-api:2.1
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
- /run:size=16m
volumes:
- app-state:/app/state
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
restart: unless-stopped
volumes:
app-state:Three lines in this file do real hardening work. read_only freezes the image content. cap_drop: ALL strips every Linux capability, including the ones the app never exercises; re-add narrowly only if the kernel feature is genuinely needed (net_bind_service for an unprivileged bind to port 80, nothing else). no-new-privileges blocks the setuid mechanism entirely, which matters even with no setuid binaries in the image because it also blocks a compromised process that tries to escalate by execve with inherited privileges.
What Needs a Writable Place
A read-only rootfs separates writes into three groups: scratch (tmpfs, fine to lose), state (a named volume, survives restarts), and cache (tmpfs or a dedicated volume). Decide which group each failure belongs to before adding exceptions. The command below is the docker run equivalent, which is also where you debug the first non-obvious failure — an app that silently needed /tmp:
docker run -d --name api \
--read-only \
--tmpfs /tmp:size=64m \
--tmpfs /run:size=16m \
-v app-state:/app/state \
--security-opt no-new-privileges:true \
--cap-drop ALL \
--cap-add net_bind_service \
-p 8080:8080 \
my-api:2.1Verifying the Configuration Holds
Trusting the flags because they are set is how hardening regressions happen. Inspect the running container for each property directly:
docker inspect api --format 'readonly={{ .HostConfig.ReadonlyRootfs }}'
docker inspect api --format 'user={{ .Config.User }} caps={{ .HostConfig.CapAdd }}'
docker ps -a
docker logs --tail 50 apiIf the container keeps restarting after the change, the logs will name the first writes you forgot to route to tmpfs or a volume. Each fix is a new tmpfs line or a new volume mount — never a surrender of the read-only rootfs itself.
Takeaway
Root inside a container is a habit, not a requirement, and a read-only rootfs is one flag with outsized consequences: no in-place binary substitution, no persistent malware, no surprise blobs at deploy time. Together, USER app, read_only, cap_drop: ALL, and no-new-privileges describe a container that resists a compromised process instead of cooperating with it. A Netbay VPS gives you the bare host, full root, and Docker-ready disk to adopt exactly this posture from the first deploy — get started at netbayhosts.in.
Keep reading
Follow along on a real VPS
Deploy Linux in under 60 seconds
These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.
Deploy an instance