Skip to content
Go back

Read-Only Docker Containers: What Breaks

By KingPin 12 min read
Read-Only Docker Containers: What Breaks
Contents

You Flipped the Switch and the Container Died in Three Seconds

You add read_only: true to a compose file. You run docker compose up -d. The container exits with code 1 before you finish your coffee. The log says “Read-only file system” and nothing else useful.

Here is the short version. A read-only root filesystem is cheap, it is worth turning on, and most images need one to three small tmpfs mounts to run. The trick is finding those paths by measurement instead of by crashing at 2 AM. I ran nginx, Postgres, Redis, Python, and Node under --read-only and wrote down every failure.

Full example: Clone the working files at github.com/KingPin/sumguy-examples/docker/docker-read-only-root-filesystem

Every error string below came from a real run on Docker 29.8.2. Tags tested: nginx:stable-alpine (1.30.6), nginxinc/nginx-unprivileged:stable-alpine, postgres:18 (18.6), redis:8 (8.10.2), python:3.14-slim (3.14.9), and node:24-slim (24.22.1).

What --read-only Actually Locks

The flag mounts the container’s root filesystem read-only. It does nothing else. It does not touch volumes, bind mounts, or tmpfs mounts, and it leaves /dev, /dev/shm, and /proc alone. I checked:

Terminal window
docker run --rm --read-only alpine sh -c 'touch /x; touch /dev/x && echo dev-writable; touch /tmp/x'
touch: /x: Read-only file system
dev-writable
touch: /tmp/x: Read-only file system

Note that /tmp is locked too. Plenty of software assumes /tmp always works, and that assumption causes most of the breakage you are about to see.

The security payoff is real. An attacker with code execution in your container cannot drop a binary into /usr/bin, edit /etc/passwd, or swap your app’s files. They can still write to whatever you leave writable, so the goal is to leave as little as possible.

Find the Writes Before You Lock the Door

Do not guess. Run the container normally, use it, and ask Docker what changed. docker diff lists every file the container added (A), changed (C), or deleted (D) relative to the image.

Terminal window
docker run -d --name probe -p 18082:80 nginx:stable-alpine
curl -s -o /dev/null localhost:18082
docker diff probe
C /var
C /var/cache
C /var/cache/nginx
A /var/cache/nginx/client_temp
A /var/cache/nginx/fastcgi_temp
A /var/cache/nginx/proxy_temp
A /var/cache/nginx/scgi_temp
A /var/cache/nginx/uwsgi_temp
C /etc
C /etc/nginx
C /etc/nginx/conf.d
C /etc/nginx/conf.d/default.conf
C /run
A /run/nginx.pid

That is your shopping list: /var/cache/nginx, /run, and a config file the entrypoint rewrote. The companion repo has find-writes.sh, which runs an image, diffs it, and prints the directories that received new files:

Terminal window
./find-writes.sh nginx:1.30-alpine
== directories that received new files (candidates for tmpfs or a volume)
/run
/var/cache/nginx

One limit: docker diff only sees what happened during your test. A nightly cron job, a first-login path, or an error handler that writes a crash dump will not show up unless you trigger it. Exercise the app, then diff. Also, docker diff on a container that is already read-only shows nothing for tmpfs paths, because tmpfs is not part of the image layer. Probe a normal run, not a locked one.

nginx: Three Different Failures in One Image

nginx is the best teacher because it fails in layers. Fix one thing and the next error appears.

Terminal window
docker run -d --name n --read-only -p 18081:80 nginx:stable-alpine
docker logs n
10-listen-on-ipv6-by-default.sh: info: can not modify /etc/nginx/conf.d/default.conf (read-only file system?)
20-envsubst-on-templates.sh: Launching ...
2026/10/22 19:02:00 [emerg] 1#1: mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system)

The first line is only an info message. The entrypoint tried to enable IPv6 in the default config, failed, and carried on. The real killer is the [emerg] line: nginx wants its temp directories. Add a tmpfs and the next wall appears:

Terminal window
docker run -d --name n --read-only --tmpfs /var/cache/nginx -p 18081:80 nginx:stable-alpine
[emerg] 1#1: open() "/run/nginx.pid" failed (30: Read-only file system)

The pid file lives in /run. Add a second tmpfs and nginx comes up and serves a 200:

Terminal window
docker run -d --read-only --tmpfs /var/cache/nginx --tmpfs /run -p 18081:80 nginx:stable-alpine

So plain nginx needs two tmpfs mounts. That is the typical size of the fix.

The envsubst Trap

If you use the official image’s template feature (files in /etc/nginx/templates), the entrypoint renders them into /etc/nginx/conf.d/. On a read-only root, that fails:

/docker-entrypoint.d/20-envsubst-on-templates.sh: line 53: can't create /etc/nginx/conf.d/default.conf: Read-only file system

The fix is a third tmpfs on /etc/nginx/conf.d. It starts empty, and the entrypoint fills it with the rendered file. In my test the rendered page carried the HOST_NAME value, so substitution works on a locked root:

compose.yaml
services:
web:
image: nginx:1.30-alpine
read_only: true
environment:
HOST_NAME: read-only-nginx
volumes:
- ./templates:/etc/nginx/templates:ro
tmpfs:
- /var/cache/nginx
- /run
- /etc/nginx/conf.d

Because that tmpfs hides the image’s own default.conf, your template has to supply the whole server block. That is usually what you want anyway.

The Unprivileged Variant

nginxinc/nginx-unprivileged runs as a non-root user and moves its temp paths to /tmp. Bare --read-only fails with:

[emerg] 1#1: mkdir() "/tmp/proxy_temp" failed (30: Read-only file system)

One mount fixes it: --tmpfs /tmp. It listens on 8080, so map accordingly. If you are choosing between the two images for a read-only setup, the unprivileged one needs one tmpfs instead of two and drops root, which is the better deal.

Postgres: The Socket Directory Nobody Remembers

Postgres is where people expect trouble, because it writes data. Good news: the data directory is a volume, and volumes stay writable. The surprise is a different path.

Terminal window
docker run -d --read-only -e POSTGRES_PASSWORD=x postgres:18
docker logs <container>
FATAL: could not create lock file "/var/run/postgresql/.s.PGSQL.5432.lock": Read-only file system

The unix socket and its lock file go in /var/run/postgresql, which resolves to /run/postgresql. A tmpfs there fixes it. Pair it with a named volume for the data. Postgres 18 images moved the data directory under /var/lib/postgresql, so mount the volume there, not at /var/lib/postgresql/data as older tags expect:

compose.yaml
db:
image: postgres:18
read_only: true
environment:
POSTGRES_PASSWORD: example
volumes:
- dbdata:/var/lib/postgresql
tmpfs:
- /run/postgresql

With both in place the log ends with “database system is ready to accept connections”, and psql answers.

Redis: Starts Fine, Fails Later

Redis is the sneaky one. Bare --read-only boots happily. You set a key and get OK. The failure arrives when the first snapshot runs, or when you call SAVE:

# Failed opening the temp RDB file temp-1.rdb (in server root dir /data) for saving: Read-only file system

The entrypoint also warns that it cannot chown /data. A container that starts and then silently loses persistence is worse than one that crashes, so test the write path, not only the startup. A named volume on /data fixes it, and redis-cli save returns OK. Redis needs no tmpfs at all in my testing.

Python: Bytecode Is Quiet, tempfile Is Loud

Python has two read-only behaviors worth separating.

Writing __pycache__ fails silently. Python tries to cache bytecode for your imported modules, hits the read-only filesystem, and moves on. I confirmed a script importing a local module ran fine on a read-only mount. You pay a small startup cost on every run. Set PYTHONDONTWRITEBYTECODE=1 to skip the attempt, or bake the .pyc files in at build time.

tempfile is the loud one. Anything that asks for a temp file dies:

FileNotFoundError: [Errno 2] No usable temporary directory found in ['/tmp', '/var/tmp', '/usr/tmp', '/app']

pip install at runtime hits the same wall, then a second one:

Terminal window
docker run --rm --read-only --tmpfs /tmp python:3.14-slim pip install requests
ERROR: Could not install packages due to an OSError: [Errno 30] Read-only file system: '/root/.local'

That failure is correct. Runtime pip install into a locked container should fail. Install dependencies at build time. If your entrypoint runs pip install, fix the Dockerfile instead of carving holes in the filesystem.

A --tmpfs /tmp covers the temp file case.

Node: npx Wants a Home Directory

A plain node -e runs fine. npx does not:

npm error enoent Invalid response body while trying to fetch https://registry.npmjs.org/cowsay: ENOENT: no such file or directory, mkdir '/root/.npm'
npm error Log files were not written due to an error writing to the directory: /root/.npm/_logs

npm wants a cache and a log directory under ~/.npm. Run as the node user and the path becomes /home/node/.npm, same error. The obvious fix is a tmpfs on ~/.npm, and here the next gotcha bites:

sh: 1: cowsay: Permission denied

npx downloads the package into ~/.npm/_npx and then executes the binary from there. A tmpfs mounted with defaults is noexec, so the download works and the execution fails. Adding :exec to that one mount fixed it:

Terminal window
docker run --rm --read-only --tmpfs /tmp --tmpfs /root/.npm:exec node:24-slim npx --yes cowsay hi

My advice: do not run npx in a production container at all. Install at build time and run node server.js. A server that only reads its own files needs no writable path beyond maybe /tmp. A docker diff of a normal npx run showed npm writing a compile cache under /tmp/node-compile-cache, which is harmless to lose. A plain node script run wrote nothing at all in my test.

tmpfs Options That Matter

I checked what Docker applies by default, because the docs are vague. Both --tmpfs /t and --mount type=tmpfs,target=/t give the same result:

tmpfs /t tmpfs rw,nosuid,nodev,noexec,relatime,inode64,huge=advise 0 0

So noexec, nosuid, and nodev are on by default in both forms. You have to opt out with :exec. That is good security by default and an occasional gotcha, as npx showed. A binary copied into a default tmpfs cannot run: I got “Permission denied” when I tried.

Size, mode, and owner are set like this:

Terminal window
docker run --rm --tmpfs /t:rw,size=16m,mode=1770,uid=1000 alpine grep ' /t ' /proc/mounts
docker run --rm --mount type=tmpfs,target=/t,tmpfs-size=16m,tmpfs-mode=1770 alpine grep ' /t ' /proc/mounts
tmpfs /t tmpfs rw,nosuid,nodev,noexec,relatime,size=16384k,mode=1770,uid=1000,inode64,huge=advise 0 0
tmpfs /t tmpfs rw,nosuid,nodev,noexec,relatime,size=16384k,mode=1770,inode64,huge=advise 0 0

In compose, the short form is - /tmp:size=16m,uid=1000. Use uid= and gid= when a non-root process owns the path. The default mode of 1777 is fine for /tmp and sloppy for anything else.

tmpfs Counts Against Your Memory Limit

A tmpfs lives in RAM, so what you write there is charged to the container’s memory cap. I tested it with a 64 MB limit and a 100 MB write into a tmpfs:

Terminal window
docker run --name mm --memory 64m --memory-swap 64m --tmpfs /t alpine \
sh -c 'dd if=/dev/zero of=/t/f bs=1M count=100'
docker inspect mm -f 'oom={{.State.OOMKilled}}'
Killed
oom=true

The container got OOM-killed. A 50 MB write against a 256 MB limit showed 51.5 MiB of usage in docker stats, so the charge is roughly one-to-one.

Also, an unsized tmpfs defaults to half the host’s RAM. On my 27 GB box df reported 13.6 GB. Always set size= on mounts that user input or logs can fill. A tmpfs with no cap and a memory limit means the OOM killer, not a tidy “disk full” error, ends your container.

The Minimum Set, Per Image

ImageWritable paths neededNotes
nginx/var/cache/nginx, /runadd /etc/nginx/conf.d for templates
nginx-unprivileged/tmplistens on 8080
postgres:18volume on /var/lib/postgresql, tmpfs /run/postgresql
redis:8volume on /datafails at first save, not at boot
python:3.14-slim/tmpPYTHONDONTWRITEBYTECODE=1
node:24-slim/tmp, plus ~/.npm:exec only if you use npx

The compose file in the example repo runs four of these together, and all four report healthy under docker compose ps.

Common Questions

Does a read-only container still allow writes to volumes?

Yes. The --read-only flag only affects the container’s root filesystem. Named volumes, bind mounts, and tmpfs mounts stay writable unless you mark them :ro. That is why Postgres and Redis work once you mount a named volume on their data directory.

Does a read-only root filesystem stop an attacker completely?

No. A read-only root blocks changes to installed binaries and config, but an attacker can still write to every tmpfs and volume you mount. Docker’s default noexec on tmpfs blocks direct execution of dropped binaries, but an interpreter can still run scripts from there. Combine a read-only root with dropped capabilities and a non-root user.

Is there a Kubernetes equivalent of read_only: true?

Yes. The container securityContext field readOnlyRootFilesystem: true does the same job, and writable paths come from emptyDir volumes (with medium: Memory for RAM-backed ones). I tested only Docker here, so check the paths you find with docker diff against your pod spec.

How much does a tmpfs cost in performance?

Almost nothing in speed, since tmpfs is RAM, but every byte counts against the container memory limit. A 50 MB write used 51.5 MiB in my docker stats check. Set size= on each tmpfs so a runaway writer hits a full-disk error instead of the OOM killer.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
UniFi in a Home Lab: Worth the Tax?

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts