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:
docker run --rm --read-only alpine sh -c 'touch /x; touch /dev/x && echo dev-writable; touch /tmp/x'touch: /x: Read-only file systemdev-writabletouch: /tmp/x: Read-only file systemNote 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.
docker run -d --name probe -p 18082:80 nginx:stable-alpinecurl -s -o /dev/null localhost:18082docker diff probeC /varC /var/cacheC /var/cache/nginxA /var/cache/nginx/client_tempA /var/cache/nginx/fastcgi_tempA /var/cache/nginx/proxy_tempA /var/cache/nginx/scgi_tempA /var/cache/nginx/uwsgi_tempC /etcC /etc/nginxC /etc/nginx/conf.dC /etc/nginx/conf.d/default.confC /runA /run/nginx.pidThat 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:
./find-writes.sh nginx:1.30-alpine== directories that received new files (candidates for tmpfs or a volume)/run/var/cache/nginxOne 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.
docker run -d --name n --read-only -p 18081:80 nginx:stable-alpinedocker logs n10-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:
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:
docker run -d --read-only --tmpfs /var/cache/nginx --tmpfs /run -p 18081:80 nginx:stable-alpineSo 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 systemThe 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:
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.dBecause 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.
docker run -d --read-only -e POSTGRES_PASSWORD=x postgres:18docker logs <container>FATAL: could not create lock file "/var/run/postgresql/.s.PGSQL.5432.lock": Read-only file systemThe 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:
db: image: postgres:18 read_only: true environment: POSTGRES_PASSWORD: example volumes: - dbdata:/var/lib/postgresql tmpfs: - /run/postgresqlWith 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 systemThe 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:
docker run --rm --read-only --tmpfs /tmp python:3.14-slim pip install requestsERROR: 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/_logsnpm 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 deniednpx 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:
docker run --rm --read-only --tmpfs /tmp --tmpfs /root/.npm:exec node:24-slim npx --yes cowsay hiMy 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 0So 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:
docker run --rm --tmpfs /t:rw,size=16m,mode=1770,uid=1000 alpine grep ' /t ' /proc/mountsdocker run --rm --mount type=tmpfs,target=/t,tmpfs-size=16m,tmpfs-mode=1770 alpine grep ' /t ' /proc/mountstmpfs /t tmpfs rw,nosuid,nodev,noexec,relatime,size=16384k,mode=1770,uid=1000,inode64,huge=advise 0 0tmpfs /t tmpfs rw,nosuid,nodev,noexec,relatime,size=16384k,mode=1770,inode64,huge=advise 0 0In 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:
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}}'Killedoom=trueThe 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
| Image | Writable paths needed | Notes |
|---|---|---|
nginx | /var/cache/nginx, /run | add /etc/nginx/conf.d for templates |
nginx-unprivileged | /tmp | listens on 8080 |
postgres:18 | volume on /var/lib/postgresql, tmpfs /run/postgresql | |
redis:8 | volume on /data | fails at first save, not at boot |
python:3.14-slim | /tmp | PYTHONDONTWRITEBYTECODE=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.