20.3.1.3 Read Only Hardening
A focused guide to Read Only Hardening, connecting core concepts with practical Docker and container operations.
Read-only hardening mounts a container's root filesystem as immutable at runtime, preventing any process inside the container from writing to the underlying image layers. This eliminates an entire class of post-compromise persistence mechanisms: an attacker who gains code execution inside a read-only container cannot install tools, modify scripts, replace binaries, or write backdoors to the container's filesystem. All attempted writes to the read-only root fail with "Read-only file system" errors.
The Problem Read-Only Hardening Solves
A standard container's writable layer allows the running process (and any attacker who compromises it) to:
- Write new executables into
/tmp,/usr/local/bin, or other paths in$PATH. - Modify existing scripts or configuration files to inject malicious behavior.
- Create persistence artifacts that survive container restarts (when volumes are mounted).
- Stage multi-stage attacks by downloading and executing tools from the internet.
A read-only root filesystem removes the local filesystem as a staging ground. The attacker's code runs in the compromised container's memory context but cannot write anything to the container's filesystem — every attempted write fails immediately.
Enabling Read-Only Mode
docker run -d --read-only my-image:latest
The --read-only flag mounts the container's root filesystem in read-only mode. All image layers are already read-only in Docker's layer model; --read-only additionally removes the writable layer that Docker adds on top.
In Docker Compose:
services:
api:
image: my-image:latest
read_only: true
Applications That Require Writes
Most applications write to the filesystem at runtime — temporary files, PID files, socket files, log files, runtime configuration. A read-only filesystem breaks these operations unless writable locations are explicitly provided.
The standard solution is to provide writable locations via --tmpfs (in-memory, ephemeral) or named volumes (persistent, on-disk):
docker run -d \
--read-only \
--tmpfs /tmp \
--tmpfs /run \
--tmpfs /var/cache \
-v app_data:/app/data \
my-image:latest
--tmpfs /tmp— in-memory writable directory for temporary files. Contents are lost when the container stops.--tmpfs /run— commonly needed for PID files, Unix sockets, and other runtime state files.--tmpfs /var/cache— writable cache directory for applications that cache data locally.-v app_data:/app/data— named volume for application data that must persist across container restarts.
Identifying Which Paths Need Write Access
Start the container in read-only mode without any tmpfs mounts and let it fail:
docker run --rm --read-only my-image:latest
The error output identifies the first write that fails:
Error: ENOENT: no such file or directory, open '/tmp/app-12345.sock'
Add a tmpfs mount for /tmp and retry. Repeat until all required write paths are covered. This iterative approach builds a minimal, explicit list of writable locations rather than guessing.
A more systematic approach is to run the container with write tracing:
docker run --rm --read-only --tmpfs /tmp --tmpfs /run my-image:latest
Watch the application logs for any filesystem write errors that appear during normal operation, not just startup.
tmpfs Configuration Options
--tmpfs accepts mount options after a colon:
docker run -d \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
my-image:latest
rw— read-write (default for tmpfs).noexec— prevents executing files placed in the tmpfs directory. This is an important additional restriction: even though the attacker cannot write to the read-only filesystem, they might try to write an executable to/tmpand execute it.noexecon/tmpprevents this.nosuid— prevents setuid bits on files in the tmpfs from taking effect.size=64m— limits the tmpfs to 64MB of RAM, preventing a runaway process from filling all available memory with a single tmpfs write.
Read-Only Mode with Specific Applications
nginx:
nginx writes PID files, temporary request buffers, and cache files. A minimal read-only configuration:
docker run -d \
--read-only \
--tmpfs /tmp \
--tmpfs /var/run \
--tmpfs /var/cache/nginx \
-p 80:80 \
nginx:alpine
Node.js applications:
Most Node.js applications only need /tmp for writable access:
docker run -d \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=32m \
-p 3000:3000 \
my-node-app:latest
PostgreSQL:
Database containers must write to their data directory — use a named volume, not tmpfs:
docker run -d \
--read-only \
--tmpfs /tmp \
--tmpfs /run/postgresql \
-v pg_data:/var/lib/postgresql/data \
postgres:15-alpine
Verifying Read-Only Mode
After starting the container, attempt a write from outside to confirm the protection is active:
docker exec my-container touch /etc/test-file
Expected output:
touch: /etc/test-file: Read-only file system
A successful write here would indicate the read-only flag was not applied correctly.
Read-Only in Docker Compose with tmpfs
services:
api:
image: my-image:latest
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
- /run
volumes:
- api_data:/app/data
volumes:
api_data:
What Read-Only Mode Does Not Prevent
Read-only hardening prevents writes to the container's filesystem. It does not:
- Prevent the process from reading any file it has permission to read.
- Prevent network connections from the container.
- Prevent the process from consuming CPU and memory.
- Prevent writes to mounted volumes (named volumes and bind mounts are still writable unless explicitly mounted read-only with
:ro).
To make a mounted volume read-only as well:
docker run -d \
--read-only \
-v /host/config:/app/config:ro \
my-image:latest
The :ro suffix on the volume mount makes that specific volume read-only in addition to the root filesystem. This is appropriate for configuration files that the container should read but never modify.
Defense in Depth
Read-only hardening is most effective when combined with non-root user configuration and capability dropping. Together, these three measures significantly raise the effort required to exploit a compromised container:
- Non-root user: limits the process's permissions on the filesystem and system calls.
- Capability drop: limits which kernel operations the process can invoke.
- Read-only filesystem: removes the local filesystem as a staging ground for post-compromise activity.
An attacker who compromises a container with all three measures applied is confined to memory-only operations, network communications, and whatever the application's legitimate code paths allow — a much more constrained attack surface than a default container.