✦ For everyone, free.

Practical knowledge for real and everyday life

Home

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 /tmp and execute it. noexec on /tmp prevents 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.