docker-compose readonly mount
Read-only bind mounts in docker-compose — security pattern, when readonly breaks tools, layered overrides.

A few months back a friend pinged me to debug a strange entry near the bottom of his nginx.conf — three lines of location block he hadn't written, sitting just above the server close-brace. We traced it through container logs and pieced together the rest: a sidecar with a chained CVE had touched the bind mount, and the mount was writable. Nothing else got exploited, but the lesson was free, and I owed the future me a writeup. Most docker-compose files I still review mount config files writable by default, even though the config side of the bind mount almost never needs write access from inside the container. The threat shape is small. The fix shape is also small. The friction is that "small" turns out to mean five different flags scattered across two pages of Compose docs, and the wrong combination breaks the container on boot with an unhelpful EROFS.
In this walkthrough I take the read-only bind-mount pattern end to end on a single docker-compose service. By the last commit you'll have five labelled checkpoints in a public repo: a writable baseline, a one-flag :ro flip, the long-form read_only: true with a tmpfs whitelist, the nginx-specific hardening that survives a real production boot, and a docker-compose.override.yml split so dev stays editable while prod is sealed. Each lesson is one git commit; clone the repo, git checkout <sha>, and run the service locally to feel the shift between writable and read-only.
The pattern is aimed at single-server operators running 5 to 30 Compose services behind nginx, on the same box where dev runs. If you already use read-only root filesystems on Kubernetes pods, the recipe below is the Compose-only subset of the same idea. The goal is a prod stack that tolerates a container compromise without losing the host-side config files, with no Compose feature newer than version 1.27.
Lesson 1: The writable bind-mount baseline (commit f7185ba)
Why would nginx.conf ever need to be writable from inside the container? It does not — and almost every starter tutorial mounts it that way anyway. A bind mount maps a host directory or file into the container, and by default the container's UID gets read AND write to that path. For an nginx.conf that holds your TLS config, your proxy_pass targets, and your rate-limit rules, that means: if anything inside the container goes wrong (RCE in a sidecar, a misconfigured PHP-FPM upstream, a chained CVE), the attacker can rewrite the host's nginx.conf and persist past container restart.
lesson-01-baseline/docker-compose.yml is the version you see in 80% of starter tutorials:
services:
web:
image: nginx:1.27-alpine
ports:
- "8080:80"
volumes:
- ./conf/nginx.conf:/etc/nginx/nginx.conf
Bring it up and docker exec in:
docker compose -f lesson-01-baseline/docker-compose.yml up -d
docker compose -f lesson-01-baseline/docker-compose.yml exec web \
sh -c 'echo malicious >> /etc/nginx/nginx.conf'
cat lesson-01-baseline/conf/nginx.conf # the "malicious" line is on the host
That last line is the threat made visible. The container appended to the file on your laptop. In a production compromise, that is a webshell rule next to your real location / blocks, and one nginx -s reload ships it to users. The bind mount is not the bug; assuming the bind mount needs to be writable is the bug.
Lesson 2: The one-character fix :ro (commit 181eadf)
Three characters is the entire fix for the threat in lesson 1 — and most teams skip them because the short-form volume syntax looks too informal to be production-grade. It maps to the OCI runtime's MS_RDONLY mount flag, which the kernel honours at syscall level. Container writes to that path return EROFS (read-only filesystem) before the write reaches the host.
services:
web:
image: nginx:1.27-alpine
ports:
- "8080:80"
volumes:
- ./conf/nginx.conf:/etc/nginx/nginx.conf:ro
Verify it landed:
docker compose -f lesson-02-readonly-short/docker-compose.yml up -d
docker compose -f lesson-02-readonly-short/docker-compose.yml exec web \
mount | grep nginx.conf
# /.../nginx.conf on /etc/nginx/nginx.conf type ext4 (ro,relatime,...)
docker compose -f lesson-02-readonly-short/docker-compose.yml exec web \
sh -c 'echo x >> /etc/nginx/nginx.conf'
# sh: can't create /etc/nginx/nginx.conf: Read-only file system
The mount output confirms ro. The write attempt confirms EROFS. The Compose docs at https://docs.docker.com/reference/compose-file/services/#volumes list this short syntax as one of the legal forms; you do not need to migrate to long form for the simple case.
The catch with :ro is scope. It only covers that one path. The rest of the container's filesystem stays writable, including /etc/nginx/conf.d/ (which is a different mount) and any path the image's Dockerfile left writable for the runtime user. For multi-mount services and stronger isolation, you want lesson 3.
Lesson 3: Long-form syntax and tmpfs for writable scratch (commit 365c9ad)
The first time I added read_only: true to a Python service it crashed on boot with three different EROFS errors I had to fix one at a time — and that turned out to be the design, not a bug. That is meaningfully stronger than per-mount :ro because it covers every path the runtime created, not just the ones you remembered to flag. The downside: real applications need at least one writable path. Python needs /tmp for __pycache__, Postgres clients need /var/run/postgresql for socket files, anything that uses mktemp needs scratch space. The pattern is read-only root plus a tmpfs whitelist that names the specific writable slices.
services:
app:
image: python:3.12-alpine
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
volumes:
- type: bind
source: ./app
target: /app
read_only: true
- type: bind
source: ./conf/app.env
target: /etc/app.env
read_only: true
working_dir: /app
command: ["python", "-u", "main.py"]
Two things are worth noticing. First, the long-form volumes: block lets you set read_only: true per mount and document the intent in YAML keys instead of a colon-separated string (compare against the short form ./app:/app:ro, which compresses everything into one line and ages badly when teammates skim a 40-service Compose file). Second, the size=16m cap on /tmp matters. An attacker who gets RCE inside the container can otherwise fill /tmp until the host runs out of inodes or memory. Sizing tmpfs is the same instinct as sizing a log volume: pick a number an order of magnitude above legitimate need and call it a day.
The lesson's app/main.py exercises the gate:
import os, time, pathlib
print("app starting; env loaded:", os.path.exists("/etc/app.env"))
pathlib.Path("/tmp/heartbeat").write_text(str(int(time.time())))
print("wrote /tmp/heartbeat ok")
try:
pathlib.Path("/canary").write_text("nope")
except OSError as e:
print("expected EROFS on /canary:", e)
time.sleep(3600)
Running it prints wrote /tmp/heartbeat ok followed by expected EROFS on /canary: [Errno 30] Read-only file system: '/canary'. Anything outside the whitelisted tmpfs paths is sealed. Reference for tmpfs sizing flags: https://docs.docker.com/engine/storage/tmpfs/.
Lesson 4: Read-only nginx without breaking it on boot (commit 28a56aa)
If you slap read_only: true on the lesson-1 nginx service, it crashes inside three seconds. nginx writes to three paths during normal operation: /var/cache/nginx for request-body buffering, /var/run/nginx.pid for the master PID file, and /var/log/nginx/*.log for access and error logs. Each of those triggers EROFS, and the master process exits with failed to create directory before the workers ever start.
The fix is the same shape as lesson 3 (read-only root plus a tmpfs whitelist sized for exactly the three needs):
services:
web:
image: nginx:1.27-alpine
read_only: true
ports:
- "8080:80"
tmpfs:
- /var/cache/nginx:size=64m,mode=1770
- /var/run:size=4m,mode=1755
volumes:
- ./conf/nginx.conf:/etc/nginx/nginx.conf:ro
- ./conf/site.conf:/etc/nginx/conf.d/default.conf:ro
The third writable path (/var/log/nginx) is handled by the official nginx image's Dockerfile, which symlinks access.log to /dev/stdout and error.log to /dev/stderr. Logs flow to your container runtime's log driver, no disk write required. That trick is documented in the nginx Docker image repo at https://github.com/nginxinc/docker-nginx and is the reason you can ship a read-only nginx without losing access logs.
Sizing: 64 MB for /var/cache/nginx covers most static-file workloads (request body buffering caps around 1 MB per upload by default, so 64 MB is roughly 64 concurrent uploads worth of headroom). 4 MB for /var/run is two orders of magnitude more than nginx ever writes there; the size cap is just defence in depth so a hostile process inside the container cannot inflate either tmpfs into a denial-of-service against the host's memory.
Bring it up and curl:
docker compose -f lesson-04-nginx-tmpfs/docker-compose.yml up -d
curl -s localhost:8080
# lesson 4 readonly nginx, tmpfs-backed cache + run
If you forget one of the two tmpfs lines, the container exits 1 within seconds and docker compose logs web shows the exact EROFS path. That is actually a usability feature: the failure mode names the next tmpfs entry you owe it. Quick comparison of the three approaches we have so far:
| Approach | Scope | Best for | Cost |
|---|---|---|---|
:ro short suffix | single mount path | one-file config drop-ins | zero (append three chars) |
read_only: true + tmpfs | whole container | hardened services, multi-mount apps | 5-15 min per service to find writable paths |
tmpfs only, no read_only | scratch directories | dev convenience containers | no security gain by itself |
That row about cost is the honest pitch. The hardening pattern takes a real chunk of time per service because every container's writable-path list is different. Budget five minutes for a static-file nginx, fifteen for an app server with a cache, an upload directory, and a PID file.
Lesson 5: Dev vs prod via docker-compose.override.yml (commit 933b1cf)
The sealed prod config is great for production. It is annoying for local development, where you want to edit site.conf, reload nginx, and not rebuild the image. The clean split is the docker-compose.override.yml mechanism: Compose merges docker-compose.yml plus docker-compose.override.yml automatically when you run docker compose up without -f. CI and prod opt out by passing -f docker-compose.yml explicitly, which makes the override invisible.
Base file (production-shaped, the version that runs in production):
services:
web:
image: nginx:1.27-alpine
read_only: true
ports:
- "8080:80"
tmpfs:
- /var/cache/nginx:size=64m,mode=1770
- /var/run:size=4m,mode=1755
volumes:
- ./conf/nginx.conf:/etc/nginx/nginx.conf:ro
- ./conf/site.conf:/etc/nginx/conf.d/default.conf:ro
Override file (dev convenience, NOT shipped to prod):
services:
web:
read_only: false
volumes:
- ./conf/nginx.conf:/etc/nginx/nginx.conf
- ./conf/site.conf:/etc/nginx/conf.d/default.conf
The override drops read_only (so you can docker exec ... vi /etc/nginx/... if you really want to) and removes :ro on both mounts, which means a live edit on the host is visible inside the container without a rebuild. tmpfs stays because there is no reason to drop it in dev.
On the dev box:
docker compose up -d # picks up override automatically – writable
On the prod box / CI:
docker compose -f docker-compose.yml up -d # base only – sealed
The deploy script enforces the prod shape by always passing -f. The dev-vs-prod merge rules are described at https://docs.docker.com/compose/multiple-compose-files/extends/. The catch worth memorising is that volumes: lists MERGE by mount path inside a service: the override file's ./conf/nginx.conf:/etc/nginx/nginx.conf replaces the base file's :ro version because both target the same in-container path. If you instead use a different in-container path, you will end up with both mounts active.
Repository
Full source at https://github.com/vytharion/docker-compose-bind-mount-readonly.
- Lesson 1 → f7185ba – baseline writable bind mount, demonstrate the threat
- Lesson 2 → 181eadf – flip the bind mount to
:roshort syntax - Lesson 3 → 365c9ad – long-form
read_only: trueplus tmpfs whitelist for/tmp - Lesson 4 → 28a56aa – read-only nginx with tmpfs for
/var/cache/nginxand/var/run - Lesson 5 → 933b1cf – dev-vs-prod split via
docker-compose.override.yml
Where to take this next
Two extensions are worth adding when you have a quiet afternoon. First, layer cap_drop: [ALL] plus security_opt: ["no-new-privileges:true"] onto every read-only service. The writable-path audit you already did for tmpfs makes this much faster, because you know which directories the process actually touches. Second, swap the bind mount for a Compose configs: block when you have more than a handful of files. Compose configs are read-only by design (the runtime materialises them via tmpfs internally) and the source of truth lives in the Compose file instead of a sibling directory the operator might forget to deploy. The trade-off is that configs are immutable per stack-up; you cannot edit them live the way you can a bind mount, so dev workflow gets slightly worse in exchange for fewer moving parts.
The pattern carries unchanged to Docker Swarm (services.<name>.configs plus read_only: true) and matches the Kubernetes readOnlyRootFilesystem: true plus emptyDir recipe one for one. If you ever migrate the stack, the writable-path list you wrote for tmpfs ports across with no edits. Keep the list in a comment at the top of each Compose service, next to the tmpfs block, and the next operator who debugs an EROFS at 2 am will thank you.