Ch. 13 · Docker

Docker Read-Only Filesystems and Writable Paths

Run a container with --read-only, find the paths it still needs to write, and give each one a sized tmpfs or volume without reopening the rest.

~5 min readadvancedupdated Oct 7, 2026

A container’s writable layer is convenient and mostly useless in production. Your application shouldn’t be modifying its own binaries, installing packages or rewriting config at runtime. Making the root filesystem read-only turns that expectation into something the kernel enforces. If an attacker gets code execution, they can’t drop a backdoor into /usr/local/bin, and if a bug writes logs to the wrong path, you find out on the first run instead of when the disk fills.

The cost is that you have to know exactly where your process writes. That’s the interesting part, and the part interviewers probe.

What --read-only changes

The flag mounts the container’s root filesystem read-only. Everything from the image is readable, nothing can be created or changed, including /tmp:

$ docker run --rm --read-only alpine:3.20 sh -c 'touch /etc/x; touch /tmp/x; echo done'
touch: /etc/x: Read-only file system
touch: /tmp/x: Read-only file system
done
Text

Docker’s own injected files follow suit. /etc/hosts, /etc/hostname and /etc/resolv.conf are bind-mounted from the daemon and also become read-only:

/dev/vda1 on /etc/resolv.conf type ext4 (ro,relatime,discard)
/dev/vda1 on /etc/hostname type ext4 (ro,relatime,discard)
/dev/vda1 on /etc/hosts type ext4 (ro,relatime,discard)
Text

That breaks the occasional entrypoint script that appends to /etc/hosts; use --add-host instead.

Any mount you add stays writable unless you mark it :ro. So the pattern is: read-only root, plus a short, explicit list of writable mounts. In Compose that’s read_only: true with tmpfs: and volumes: entries; in Kubernetes it’s readOnlyRootFilesystem: true with emptyDir volumes.

Finding the paths a real image writes to

Guessing doesn’t work well. Run the image read-only and let it tell you. Here’s the official nginx:alpine image:

$ docker run -d --name ng --read-only nginx:alpine
$ docker logs ng
...
/docker-entrypoint.sh: Configuration complete; ready for start up
nginx: [emerg] mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system)
$ docker inspect -f 'status={{.State.Status}} exit={{.State.ExitCode}}' ng
status=exited exit=1
Text

nginx creates temp directories for request bodies and proxy buffering under /var/cache/nginx, and writes its PID file to /var/run/nginx.pid. Give both a tmpfs:

docker run -d --name ng --read-only \
  --tmpfs /var/cache/nginx \
  --tmpfs /var/run \
  nginx:alpine
Terminal

Now it starts and serves the default page. The logs show one more thing worth reading:

10-listen-on-ipv6-by-default.sh: info: can not modify /etc/nginx/conf.d/default.conf (read-only file system?)
Text

The image’s entrypoint normally edits the default config at start-up to add an IPv6 listen line. It couldn’t, so nginx listens on IPv4 only. Inside the container, localhost resolves to ::1 first, and the result is a subtle split:

$ docker exec ng wget -qO- http://127.0.0.1/ | grep -o '<title>.*</title>'
<title>Welcome to nginx!</title>
$ docker exec ng wget -qO- http://localhost/
wget: can't connect to remote host: Connection refused
Text

Nothing crashed, but a health check written against localhost would now fail. This is typical of read-only hardening: the hard failures are easy, and the soft ones hide in entrypoint scripts that “helpfully” modify files at start-up. The right fix is to stop relying on runtime edits: bake the final default.conf into your image (or mount it read-only), including the listen [::]:80; line if you want IPv6.

For your own application, look for writes to temp directories, caches (~/.cache, ~/.npm, Python’s __pycache__), PID and lock files, upload staging, and local log files. Running the container with a writable root and then docker diff <container> after exercising it is a quick way to list every path that changed.

tmpfs, volumes and the options that matter

A tmpfs lives in memory and disappears when the container stops. It’s the right home for scratch data: temp files, PID files, caches you can rebuild. A volume is for anything that should survive a restart, like uploaded files or a SQLite database.

Two tmpfs details matter in production. First, size. An unsized tmpfs can grow to half the host’s RAM by default (on a Docker Desktop VM with 3.8 GB, df /tmp reported 1.9G), and what’s written there is charged to the container’s memory cgroup. Give it a cap:

$ docker run --rm --read-only --tmpfs /tmp:size=1m alpine:3.20 \
    dd if=/dev/zero of=/tmp/big bs=1k count=2048
dd: error writing '/tmp/big': No space left on device
1024+0 records out
Text

A full tmpfs gives the app a clear ENOSPC; an unsized one that eats the memory limit gets the container OOM-killed instead, which is harder to diagnose. The tmpfs note covers the memory accounting in more detail.

Second, noexec. Docker mounts a --tmpfs with rw,nosuid,nodev,noexec by default. That’s a real defence, since a common attack is to download a payload to /tmp and run it:

$ docker run --rm --read-only --tmpfs /tmp alpine:3.20 sh -c 'printf "#!/bin/sh\necho ran\n" > /tmp/s; chmod +x /tmp/s; /tmp/s'
sh: /tmp/s: Permission denied
$ docker run --rm --read-only --tmpfs /tmp:exec alpine:3.20 sh -c '...same script...'
ran
Text

Some software legitimately executes from temp (JNI libraries extracted by Java, some Python wheels with native code, Prisma’s query engine). Add exec only to the specific mount that needs it, after you’ve confirmed which one.

What it does and doesn’t protect

A read-only root blocks modifications to the image’s files and makes container state predictable, so two replicas really are identical. It doesn’t stop an attacker from reading secrets, making network calls, using writable mounts, or abusing a process that runs as root with broad capabilities. Read-only is one layer, combined with a nonroot user, --cap-drop ALL plus the few you need, and no privileged mode. It also costs very little at runtime, so the hard part is purely the up-front work of listing writable paths. That’s why it shows up as an “advanced” topic: the flag is trivial, knowing your process isn’t.

Interview tip

Add --read-only in CI tests too, not just production. A dependency upgrade that starts writing to a new cache path then fails the pipeline rather than the deploy.

Interview practice

You enable readOnlyRootFilesystem for a Spring Boot service. It fails to start with Unable to create tempDir. java.io.tmpdir is set to /tmp. After you add a tmpfs at /tmp, it starts, but a later request fails with an UnsatisfiedLinkError mentioning a .so file in /tmp. Explain both, and describe the narrowest configuration that works.

Answer

The first error is the embedded Tomcat creating its work directory under java.io.tmpdir, which is /tmp and was read-only. Adding a tmpfs fixed the write. The second error is a native library (Netty’s native transport or a compression library are common examples) being extracted into /tmp and loaded with dlopen, which fails because Docker’s tmpfs is noexec by default.

The narrowest fix keeps /tmp noexec and gives native extraction its own small executable mount. Many libraries let you choose the extraction directory, for example -Dio.netty.native.workdir=/opt/native pointing at a separate tmpfs mounted with exec and a modest size. If the library can’t be redirected, mount /tmp with exec but keep it sized (say 64 MB) and keep the root filesystem read-only. Run docker diff against a writable test run to confirm nothing else is written, and keep the container nonroot so the executable tmpfs isn’t combined with root privileges.

Keep going

More in Docker

read ✓Docker · mid

Docker Nonroot Users and File Permissions

Switching a container to a nonroot USER breaks writes until ownership is right. Fix EACCES with chown, numeric UIDs and pre-created volume paths.

~5 min readread →
read ✓Docker · mid

Docker Exposed Ports Versus Published Ports

EXPOSE documents a port; -p opens one on the host. Bind addresses, -P, the 127.0.0.1 trap inside containers and the ufw bypass on Linux.

~5 min readread →
esc