Read-Only Filesystems

A Docker read-only filesystem starts a container with its root filesystem mounted so the process cannot write to the image-backed paths. This matters because many attacks and accidents depend on changing files inside the container: dropping tools, overwriting configs, modifying startup scripts, or filling the writable layer.

Read-only mode is not a complete sandbox, but it is a strong hardening step. You combine it with intentionally writable mounts for the few paths an application truly needs, such as /tmp, /run, or a data volume.

Overview: How Read-Only Filesystems Work

A Docker image is built from read-only layers. When Docker normally starts a container, it stacks those image layers and adds a thin writable container layer on top. If the process writes to /etc/app.conf, creates /tmp/file, or changes a package file, Docker records those changes in that container writable layer. The image itself is unchanged, but the running container has mutable state.

--read-only changes that last part. Docker still uses the same image layers, but it mounts the container’s root filesystem as read-only, so writes to ordinary root filesystem paths fail with errors such as Read-only file system. The container process can still read files from the image. It can still write to paths that are separate writable mounts, including named volumes, bind mounts, and tmpfs mounts. This is why read-only mode is useful: it makes write access explicit instead of letting the whole container filesystem become scratch space.

Under the hood, Docker asks the container runtime to create a mount namespace for the container. In normal mode, the overlay filesystem exposes read-only lower image layers plus a writable upper layer. In read-only mode, Docker configures the root mount so the process cannot write through it. Additional mounts are then placed at their target paths. A tmpfs at /tmp covers that path with a memory-backed writable filesystem, while a named volume at /var/lib/myapp gives the application durable storage there.

This feature is especially important for security because containers are usually built to be replaced, not repaired in place. Production containers should not install packages at runtime, rewrite application binaries, or generate permanent configuration under /usr or /etc. If an attacker reaches remote code execution inside a container, read-only mode can prevent simple persistence techniques and make post-exploitation harder. If a bug writes logs to the wrong path, read-only mode also surfaces the bug immediately instead of silently growing the container layer.

There are limits. A read-only root filesystem does not prevent network access, CPU abuse, memory abuse, writes to mounted volumes, or all kernel-level attacks. It should be used with other controls such as non-root users, dropped Linux capabilities, resource limits, image scanning, secret management, and narrow network exposure.

Syntax

The basic docker run form is:

docker run --read-only IMAGE COMMAND

Most real applications need one or more writable paths:

docker run --read-only --tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m IMAGE COMMAND
docker run --read-only --mount type=volume,source=app-data,target=/data IMAGE COMMAND
Option Meaning
--read-only Mounts the container root filesystem read-only. Ordinary writes to image-backed paths fail.
--tmpfs /path:options Adds a writable memory-backed filesystem at a container path. Good for runtime scratch data.
--mount type=tmpfs,target=/path Long-form tmpfs syntax. Useful when you prefer key-value mount options.
--mount type=volume,source=name,target=/path Mounts a Docker-managed named volume. Good for durable application data.
--mount type=bind,source=/host/path,target=/path Mounts a specific host path. Common in local development, but be careful in production.

In Compose, use read_only: true on the service and add only the writable mounts the service needs:

services:
  app:
    image: alpine:3.20
    command: sh -c 'echo "started" > /run/app/status && sleep 300'
    read_only: true
    tmpfs:
      - /run/app:size=16m,mode=1777
    volumes:
      - app-data:/data
volumes:
  app-data:

Use the modern docker compose subcommand. The old standalone docker-compose command may exist on older systems, but new projects should use Compose V2 through docker compose.

Examples

Example 1: Block writes to the container root filesystem

This container can run commands from the image, but it cannot create a new file directly under /:

docker run --rm --read-only alpine:3.20 sh -c 'echo "container started" && touch /blocked.txt || echo "root filesystem blocked the write"'

Output:

container started
touch: /blocked.txt: Read-only file system
root filesystem blocked the write

Docker started the container from the pinned alpine:3.20 image tag, then mounted the root filesystem read-only. The echo command worked because it only wrote to standard output. The touch command failed because it tried to create a file on the read-only root filesystem. Pinning the image tag avoids the moving-target behavior of latest.

Example 2: Add a writable /tmp for scratch files

Many programs expect /tmp to be writable. Keep the root filesystem read-only, but provide a small tmpfs mount for temporary files:

docker run --rm --read-only --tmpfs /tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777 alpine:3.20 sh -c 'printf "cache\n" > /tmp/cache.txt && cat /tmp/cache.txt && touch /etc/changed.conf || echo "etc write blocked"'

Output:

cache
touch: /etc/changed.conf: Read-only file system
etc write blocked

The application can write to /tmp because that path is now a separate memory-backed filesystem. The restrictive noexec, nosuid, and nodev options reduce what can be done from that temporary directory. A write to /etc still fails, which is the point of read-only mode.

Example 3: Keep durable data in a named volume

Read-only roots do not mean stateless-only applications. Put durable state on a named volume and keep the rest of the container locked down:

docker volume create ro-demo-data
docker run --rm --read-only --mount type=volume,source=ro-demo-data,target=/data alpine:3.20 sh -c 'echo "order-123" >> /data/orders.log && cat /data/orders.log && touch /usr/local/bin/changed || echo "program path blocked"'

Output:

ro-demo-data
order-123
touch: /usr/local/bin/changed: Read-only file system
program path blocked

The named volume is writable because it is a separate mount at /data. The application can persist real data there, while application binaries and image-provided files remain read-only. This is a common production shape: immutable image, explicit data volume, and small temporary filesystems for runtime scratch paths.

Example 4: Use read-only mode in Compose

Compose lets you keep the hardening rule with the service definition:

services:
  api:
    image: alpine:3.20
    command: sh -c 'echo "ready" > /run/api/status && echo "saved" > /data/state.txt && sleep 300'
    read_only: true
    tmpfs:
      - /run/api:size=16m,mode=1777
    volumes:
      - api-data:/data
volumes:
  api-data:

Run the project, read both writable locations, and remove the container:

docker compose up -d
docker compose exec api cat /run/api/status
docker compose exec api cat /data/state.txt
docker compose down

Output:

[+] Running 3/3
 - Network readonly-demo_default  Created
 - Volume readonly-demo_api-data  Created
 - Container readonly-demo-api-1  Started
ready
saved
[+] Running 2/2
 - Container readonly-demo-api-1  Removed
 - Network readonly-demo_default  Removed

docker compose down removes the container and network. The named volume normally remains unless you remove it with docker compose down -v. That distinction is useful: /run/api is temporary runtime state, while /data is durable application state.

How It Works Step By Step

  1. The Docker CLI sends the image, command, and --read-only setting to the Docker daemon.
  2. The daemon resolves or pulls the image tag, then prepares the container filesystem from the image’s read-only layers.
  3. Instead of exposing a normal writable root layer to the container process, Docker configures the root mount as read-only.
  4. Docker applies additional mounts such as tmpfs, named volumes, and bind mounts at their target paths. These mounts can be writable even though the root filesystem is not.
  5. The container process starts in its own mount namespace. Reads from image paths work normally. Writes to image-backed paths fail with a read-only filesystem error.
  6. Writes below a writable mount go to that mount. A tmpfs write stays in memory and disappears with the container. A named volume write persists in Docker-managed storage.
  7. When the container stops, Docker removes runtime resources. The image remains unchanged, temporary mounts are gone, and named volumes remain until explicitly removed.

Common Mistakes

Turning on read-only mode without writable runtime paths

This fails for software that needs /tmp, /run, PID files, sockets, caches, or lock files:

docker run --rm --read-only alpine:3.20 sh -c 'echo "pid" > /run/app.pid'

The fix is not to disable read-only mode. The fix is to identify the runtime path and mount only that path:

docker run --rm --read-only --tmpfs /run:rw,noexec,nosuid,nodev,size=16m,mode=1777 alpine:3.20 sh -c 'echo "pid" > /run/app.pid && cat /run/app.pid'

Using a bind mount that gives back too much power

A broad writable bind mount can undo much of the hardening:

docker run --rm --read-only --mount type=bind,source=/,target=/host alpine:3.20 sh -c 'ls /host'

This mounts the host root into the container. Even if the container root is read-only, the host path may expose sensitive files or writable areas. Use narrow mounts, prefer named volumes for application data, and make bind mounts read-only when the container only needs to read from them.

Expecting --read-only to protect secrets in environment variables

Read-only mode controls filesystem writes. It does not hide environment variables, command arguments, mounted files, or network traffic. Do not bake secrets into images with ENV, and do not rely on --read-only as a secret manager. Use Docker secrets or your orchestrator’s secret store.

Trying to patch containers at runtime

Some teams install packages or edit config files inside running containers. That is fragile even without read-only mode, and it usually fails with read-only mode. Rebuild the image from a Dockerfile, pin base image tags, scan the result, and redeploy a new container.

Best Practices

  • Enable --read-only or Compose read_only: true for production services unless the image genuinely requires a mutable root filesystem.
  • Start with no writable mounts, run the application, then add the smallest required tmpfs or volume targets based on actual failures.
  • Use tmpfs for runtime scratch paths such as /tmp, /run, sockets, PID files, and short-lived caches.
  • Use named volumes for durable data such as database files, uploaded user content, and application state.
  • Avoid broad writable bind mounts in production. If a bind mount is needed for configuration, make it read-only.
  • Run as a non-root user as well. A read-only root filesystem limits writes, but root inside the container still has more power than most applications need.
  • Combine read-only mode with dropped capabilities, resource limits, health checks, image scanning, and careful secret handling.
  • Do not write logs only to files inside the container root. Prefer standard output and standard error so docker logs and your logging system can collect them.
  • Document every writable mount in Compose. Each one should have a reason: temporary runtime data, durable data, or an intentional host integration.

Practice Exercises

  1. Run an alpine:3.20 container with --read-only. Confirm that writing to /blocked.txt fails, then add a tmpfs mount at /scratch and confirm writing there succeeds.
  2. Create a Compose service with read_only: true, a tmpfs mount at /run/app, and a named volume at /data. Expected end state: runtime status goes to /run/app and durable state goes to /data.
  3. Audit a Dockerized app you already have. List every path it writes to during startup and normal use. Decide whether each path should be standard output, tmpfs, a named volume, or a read-only bind mount.

Summary

  • --read-only mounts the container root filesystem read-only, so image-backed paths cannot be changed at runtime.
  • Read-only mode works because containers are built from read-only image layers plus runtime mounts.
  • Use tmpfs for temporary writable paths and named volumes for durable data.
  • Writable mounts remain writable, so keep them narrow and intentional.
  • Read-only filesystems reduce persistence and accidental mutation, but they are only one part of container security.
  • Compose supports the same pattern with read_only: true, tmpfs, and volumes.