Skip to Content
Docker Advanced: Builds and Containers That Hold Up in Production
Advanced
0 likes
Courses
Docker Advanced: Builds and Containers That Hold Up in Production

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.

Course Home

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. Run docker diff on 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
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.