Skip to content

CL-0007: read_only — fixing “Read-only file system” errors

Severity: LOW

Derivation (see severity model):

  • Baseline: A — the attacker already has code execution in this container, as the workload uid
  • Precondition: Removes a mitigation — a writable rootfs grants no new privilege. It removes a constraint on what an attacker who already has code execution can leave behind
  • Impact: Single container — writes land in this container's own writable layer
  • Qualifier/modifier: none
  • Derived: Removes a mitigation × Single container = LOW
  • Shipped: LOW
  • Evidence: _cl0007 (the rootfs is writable by default) and _t7_volume_writable (a named volume stays writable under read_only, which is why persistence through a volume is out of this rule's reach)

References: - OWASP Docker Security Rule #8 - CIS Docker Benchmark 5.13 — Ensure that the container's root filesystem is mounted as read only

What it detects

Any service that does not set read_only: true, including services that omit the key entirely or set read_only: false.

Why it matters

A writable root filesystem allows an attacker who gains code execution inside a container to:

  • Modify application binaries to inject backdoors
  • Install additional attack tools (curl, wget, netcat)
  • Modify configuration files to weaken security settings

A read-only filesystem forces the application to use only explicitly declared writable paths, limiting what an attacker can modify.

What it does not cover: persistence through a volume. Named volumes and bind mounts stay writable under read_only: true — that is the whole point of declaring them — so anything an attacker writes there survives a restart regardless of this setting. _t7_volume_writable asserts exactly that on every CI run. An earlier revision of this page listed "persist malware that survives container restarts (if using bind mounts or named volumes)" as something read_only prevents; it named the two cases the setting specifically does not reach. read_only narrows persistence to the paths you chose to make writable; it does not eliminate it.

Fix

Set the root filesystem to read-only and declare writable paths with tmpfs:

services:
  web:
    image: nginx:1.27-alpine
    read_only: true
    tmpfs:
      - /tmp
      - /run
      - /var/cache/nginx

Common writable paths applications may need: - /tmp — temporary files - /run — PID files and sockets - /var/log — application logs (prefer stdout/stderr instead) - Application-specific cache directories

Compatibility

read_only: true is an advanced hardening step. Most images write to paths beyond /tmp and /run at startup, and the minimal tmpfs list above is rarely sufficient on its own.

Observed failure modes:

  • Images that write state or registry files under image-specific paths (e.g. netdata writes to /etc/netdata/.container-hostname and /var/lib/netdata/registry).
  • Entrypoints that chown their data directory on first boot (e.g. valkey, redis). On a read-only rootfs chown fails unless the CHOWN capability is retained and the target path is on a writable mount.
  • Applications that rewrite their own config at startup (some exporters, some TLS-fetching sidecars).

Writable-path discovery

  1. Run the service without read_only and let it reach a steady state — exercise its real functionality, not just startup.
  2. Inspect what was written:

docker diff <container>

read_only blocks exactly the writable-layer writes docker diff records, so the diff is a near-complete preview of what will break. (Volume paths don't appear in it — they stay writable under read_only anyway.) 3. Add each written path as a tmpfs entry (ephemeral data) or a named volume (persistent data) using the table below. 4. Enable read_only: true, restart, and re-verify function, not just startup: applications routinely catch a write failure and continue with the feature silently disabled (caching off, uploads rejected, temp exports failing), so read the logs and exercise the service before calling it done.

If the entrypoint runs chown, either pre-own the mount as the target UID:GID in the image build, or drop that specific service from the rule scope.

Reading the failure

A blocked write surfaces as Read-only file system (EROFS) in the container logs, naming the path — the remedy follows from what kind of path it is. The busybox wordings below are re-proven against a live container on every CI run (scripts/validate_rule_premises.py: the write must fail under read_only: true with this message and succeed with the mapped remedy); coreutils variants were captured live from a Debian container but are not CI-asserted.

Symptom in logs (verbatim) Path type Remedy
touch: /tmp/scratch: Read-only file system (busybox) · touch: cannot touch '/run/app.pid': Read-only file system (coreutils) ephemeral runtime state: PID files, sockets, scratch space tmpfs: entry for the directory
mkdir: can't create directory '/var/cache/': Read-only file system (busybox) · mkdir: cannot create directory '/var/cache/app': Read-only file system (coreutils) caches tmpfs:, or a named volume if a warm cache across restarts matters
can't create /etc/…: Read-only file system from the entrypoint shell config rewritten at startup named volume for that path, pre-render the config in the image build, or accept that read_only doesn't fit this image and suppress with a documented reason
any EROFS under a data directory (database files, uploads) persistent application data named volume — never tmpfs. Named volumes stay writable under read_only (CI-proven). A tmpfs here runs green until the first restart, then silently erases the data
No such file or directory on a path the app would normally create at startup directory absent from the image and rootfs unwritable — the EROFS is masked by the failed mkdir a tmpfs: entry both creates the mount point and makes it writable

The last two rows are the traps: the naive fix — "add whatever path the error names as tmpfs" — is exactly wrong for persistent data, and its failure mode is invisible until a restart; and a missing-directory error under read_only is usually this rule's breakage wearing a different message.

Hardened non-root images

An image built to run as a non-root user is not automatically read-only-ready — it typically still writes to paths its uid owns (caches, PID files, runtime state). The two hardening steps are independent: non-root limits who the process is, read_only limits where it can write, and the second still needs the writable paths declared. The same recipe applies — ephemeral writes to tmpfs, persistent writes to a named volume, docker diff to discover the paths:

services:
  web:
    image: nginxinc/nginx-unprivileged:1.27   # runs as uid 101
    read_only: true
    tmpfs:
      - /tmp
      - /var/cache/nginx
      - /var/run

Further reading

ATT&CK coverage

No adversary technique maps to this rule. That is a finding about the rule, not an omission — see below.