Read-Only Root Filesystems and tmpfs
Find where a service writes with docker diff, lock its root filesystem and give it sized tmpfs mounts. After this, you can run nginx and your own apps read-only without guessing.
Course content
Sign in to save your progress.
-
Faster Builds
-
BuildKit: Cache Mounts and Why Rebuilds Are Slow
-
Multi-Stage Builds Without Shipping the Compiler
-
-
-
Images That Don't Leak
-
Non-Root Users and Dropping Capabilities
-
Pin Digests, Base Image Updates and CVEs
-
-
-
Running Under Load
-
Resource Limits, Restart Policies and Healthchecks
-
Logging Drivers and What Happens When Disk Fills
-
-
-
Beyond One Compose File
-
Compose Profiles, Watch and Override Files
-
Private Registries and Auth Without Sticking Tokens in Files
-
-
-
Production Concerns
-
Read-Only Root Filesystems and tmpfs
-
Rolling a Service Forward on One Host
-
When Docker Desktop Is the Wrong Tool
-
-
- Final Check: Docker Advanced
Skip ahead: If your containers already run with --read-only and you know which paths each one writes to, skip to "Size limits on tmpfs" and "Common mistakes".
If an attacker gets code running in your container, the first thing they want is to write something: a script, a binary, a changed config file. A read-only root filesystem takes that away. The container can still write where you allow it, usually a small temporary folder in memory and a volume for real data, and nowhere else. It is one flag. The work is finding out where your program writes, and this lesson shows a quick way to find out instead of guessing.
Run on Docker Desktop 4.85.0 with Engine 29.6.2, using alpine:3.24 and nginx:1.30-alpine (nginx 1.30.6).
One flag
docker run --rm --read-only alpine:3.24 sh -c "touch /etc/changed; echo exit code `$?"
touch: /etc/changed: Read-only file system
exit code 1
The image layers were always read-only. Normally Docker adds a thin writable layer on top, and --read-only simply leaves it out. Nothing is copied and nothing is slower.
Find out where the program writes
Before you lock a real service down, run it normally and ask Docker what changed in its writable layer. docker diff lists added (A), changed (C) and deleted (D) paths since the container started:
docker run -d --name plain-nginx nginx:1.30-alpine | Out-Null
Start-Sleep -Seconds 3
docker diff plain-nginx
C /etc
C /etc/nginx
C /etc/nginx/conf.d
C /etc/nginx/conf.d/default.conf
C /var
C /var/cache
C /var/cache/nginx
A /var/cache/nginx/fastcgi_temp
A /var/cache/nginx/proxy_temp
A /var/cache/nginx/scgi_temp
A /var/cache/nginx/uwsgi_temp
A /var/cache/nginx/client_temp
C /run
A /run/nginx.pid
That is the list. nginx writes its cache folders under /var/cache/nginx, its process id file under /run, and its startup script changes /etc/nginx/conf.d/default.conf (it adds an IPv6 listen line). Those are the paths that need to stay writable, or be prepared in the image.
Now lock it
First, read-only with no exceptions, to see how it fails:
docker run -d --name ro-nginx --read-only nginx:1.30-alpine | Out-Null
Start-Sleep -Seconds 3
docker logs ro-nginx 2>&1 | ForEach-Object { "$_" } | Select-String "emerg|can not modify|Read-only"
docker inspect ro-nginx --format "running={{.State.Running}} exit code={{.State.ExitCode}}"
10-listen-on-ipv6-by-default.sh: info: can not modify /etc/nginx/conf.d/default.conf (read-only file system?)
2026/10/20 17:14:24 [emerg] 1#1: mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system)
nginx: [emerg] mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system)
running=false exit code=1
The entrypoint script could not change its config file and said so, then nginx stopped because it could not create its cache folder. Note that the script did not stop the container. It only printed a message and skipped the change. Always read the startup log of a locked-down container, not only the exit code.
Now give nginx memory-backed folders where it needs to write, with --tmpfs:
docker run -d --name ro-nginx-ok --read-only `
--tmpfs /var/cache/nginx --tmpfs /run --tmpfs /tmp `
nginx:1.30-alpine | Out-Null
Start-Sleep -Seconds 3
docker inspect ro-nginx-ok --format "running={{.State.Running}}"
docker exec ro-nginx-ok wget -qO- http://127.0.0.1/ | Select-String "<title>"
docker exec ro-nginx-ok sh -c "echo hacked > /usr/share/nginx/html/index.html"
running=true
<title>Welcome to nginx!</title>
sh: can't create /usr/share/nginx/html/index.html: Read-only file system
It runs, it serves the default page, and the page cannot be replaced from inside. The IPv6 line was not added, because the config file was still read-only. That is fine here, and if you need it, write the line into the config file in your own image instead of changing it at startup.
tmpfs: memory, not disk
A tmpfs mount lives in RAM. It is fast, it is never written to the host's disk, and it is empty every time the container starts. That makes it right for caches, lock files and process id files, and wrong for anything you must keep. For data, use a volume, exactly as before. A read-only root and a volume for /var/lib/postgresql/data go together fine.
Size limits on tmpfs
A tmpfs without a size can grow up to half the memory of the machine, which on Docker Desktop means the VM. Give it a size, and the container's own memory limit still counts it:
docker run --rm --read-only --tmpfs /tmp:size=8m alpine:3.24 sh -c "dd if=/dev/zero of=/tmp/fill bs=1M count=20 2>&1 | tail -n 1; df -h /tmp | tail -n 1"
8388608 bytes (8.0MB) copied, 0.006701 seconds, 1.2GB/s
tmpfs 8.0M 8.0M 0 100% /tmp
The write stopped at 8 MB with "No space left on device". The container did not die. Only that write failed, which is the behaviour you want.
In Compose
services:
web:
image: nginx:1.30-alpine
read_only: true
tmpfs:
- /var/cache/nginx:size=16m
- /run:size=1m
- /tmp:size=16m
docker compose -f ro-stack/compose.yaml up -d --wait 2>$null
docker compose -f ro-stack/compose.yaml exec web sh -c "touch /etc/x; echo exit code `$?"
docker compose -f ro-stack/compose.yaml down 2>$null
touch: /etc/x: Read-only file system
exit code 1
⚠ Warning: A read-only root does not make a container safe on its own. An attacker can still run code that is already in the image and use anything writable, including your tmpfs folders. Combine it with a non-root user and dropped capabilities from the earlier lesson.
Common mistakes
- Turning on
--read-only, seeing the container exit, and turning it off again. Rundocker diffon a normal container first, and you have the list in a minute. - Putting a tmpfs over a folder that holds files from the image. A tmpfs mount hides what was there, so the folder looks empty.
- Forgetting that some apps write to
$HOME(for example~/.cache). Set the variable to a tmpfs path, or create the folder as part of the image. - Storing uploads on a tmpfs. They disappear at the next restart.
💡 Tip: Add docker diff to your test pipeline for each image. If the list of written paths changes between releases, someone added a write you didn't plan for.
If someone could run one command inside your web container right now, what file could they change that your users would see?
About
Find where a service writes with docker diff, lock its root filesystem and give it sized tmpfs mounts. After this, you can run nginx and your own apps read-only without guessing.
- Duration
- 5 min
- Last update
- 08/01/2026
Comments (0)
Sign in to commentNo comments yet.