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 underread_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.
netdatawrites to/etc/netdata/.container-hostnameand/var/lib/netdata/registry). - Entrypoints that
chowntheir data directory on first boot (e.g.valkey,redis). On a read-only rootfschownfails unless theCHOWNcapability 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¶
- Run the service without
read_onlyand let it reach a steady state — exercise its real functionality, not just startup. - 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.