✦ For everyone, free.

Practical knowledge for real and everyday life

Home

Read Only Hardening

Read Only Hardening ensures containers run securely by preventing unintended modifications, enhancing stability and reducing risk in Docker environments

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.