CoursesAdvanced container securityRead-only root filesystem

Read-only root filesystem

Immutable at runtime, with explicit writable paths.

Advanced12 min · lesson 11 of 25

Break into a house and the first thing you look for is somewhere to stash your gear. An attacker who gets code running inside a container does the same thing, and the stash is disk. They drop a reverse shell (a small program that dials back out to the attacker and hands them a command prompt), a crypto miner, a stolen SSH key (SSH = secure shell, the normal way people log into a Linux machine remotely), or a tweaked entrypoint (the command a container runs the moment it starts) that relaunches their tooling after the next restart. Persistence lives on the filesystem. A read-only root filesystem takes the stash away. Mount the container's rootfs (root filesystem: everything the container sees under /) read-only and the attacker still has code execution, but nowhere to write anything down. Nothing they drop survives a restart. Nothing they edit sticks. Better still, the failed write is a signal you can alert on, because a healthy container almost never writes to its own program files.

terminal
$ docker run -d --name web --read-only \
--tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m \
-v webdata:/var/lib/app \
alpine:3.20 sleep infinity
9c1f2a7b3e14
# attacker has a shell and tries to plant a backdoor in the image
$ docker exec web sh -c 'echo pwned > /usr/local/bin/backdoor'
sh: can't create /usr/local/bin/backdoor: Read-only file system
$ echo $?
1

What read-only actually locks

To see why that write failed, you need to know what a container's root filesystem really is. A container image is a stack of layers, like a pile of transparent sheets on a desk, each sheet recording only what changed from the sheet below it. When the container starts, the runtime lays down the image sheets, which are read-only, then drops one blank sheet on top for the running process to scribble on. Everything you see at / is all of those sheets merged into a single view. That merged view is an overlay filesystem, and the blank top sheet is the upper layer. Normally your writes land there. The --read-only flag mounts the whole rootfs read-only, so a write to any path that came out of the image is refused by the kernel (the core of the operating system, which decides what a program is allowed to do) with the error EROFS, short for read-only filesystem. You see it as "Read-only file system". That covers /, /usr, /etc, /bin, your app directory, everything baked into the image. What it does not cover is any mount you add on top. That gap is where most mistakes hide.

detect / audit
# fleet audit: which running containers are NOT immutable?
$ docker inspect $(docker ps -q) \
--format '{{ .Name }} readonly={{ .HostConfig.ReadonlyRootfs }}'
/web readonly=true
/legacy-api readonly=false # <-- flag this one
# runtime proof from inside: the merged root really is mounted ro
$ docker exec web sh -c "grep ' / ' /proc/mounts"
overlay / overlay ro,relatime,lowerdir=/var/lib/docker/overlay2/l/ABC...,upperdir=...,workdir=... 0 0

Those two commands answer two different questions. The docker inspect line reads the declared config, the setting Docker was asked for, so you can sweep every container on a host and catch the one that shipped without --read-only. The /proc/mounts line is the truth from inside the container while it runs, because /proc/mounts is the kernel's own live list of every mount currently in place. The little ro token on the overlay mount proves the kernel is really enforcing it, not that somebody typed the flag and hoped. Once the container is running you get a third signal for free. A normal workload never writes to its own binaries, so a failed write to a read-only path is loud. Point a Falco rule at writes under an etc or bin directory (Falco is a runtime security tool that watches what containers do and raises alerts), or set an auditd watch (auditd = the Linux audit daemon, the operating system's own logging service) that catches the EROFS return code. Now "an attacker touched the filesystem" reaches you as a page tonight instead of a nasty discovery weeks later.

Give writes back on purpose

Almost every real app writes somewhere. It wants a scratch directory, a PID file (PID = process ID, the number the kernel gives a running program, usually written to a small file so other tools can find it), a socket, a cache. So you don't switch read-only on and cross your fingers. You switch it on and then hand back exactly the paths that must be writable, one at a time, the way a landlord hands over one key per door instead of the master key. Two tools do the handing back. A tmpfs (temporary filesystem) is a small filesystem that lives in RAM (random access memory, the fast memory that empties when the power goes): quick, writable, and wiped the second the container stops, which makes it right for temp files and runtime scratch that should never outlive the process. A named volume is durable storage that survives the container, for data you actually want to keep. Either way, every writable location is now written down in the run spec where you can audit it, instead of "the whole filesystem is writable and nobody knows what changed." Finding the paths is easy. Run the container once under --read-only and read the errors it throws at you. Nginx, for one, will tell you it needs /var/cache/nginx and /var/run before it agrees to boot. Give each one a tmpfs or a volume and move on.

terminal
# the tmpfs we mounted for /tmp IS writable, on purpose
$ docker exec web sh -c 'echo scratch > /tmp/work && cat /tmp/work'
scratch
# confirm what actually took effect: noexec and nosuid are on
$ docker exec web sh -c "grep ' /tmp ' /proc/mounts"
tmpfs /tmp tmpfs rw,nosuid,nodev,noexec,relatime,size=65536k 0 0
Your tmpfs already has noexec. Don't take it off.
A read-only rootfs stops writes to the image, but every tmpfs you add is a separate mount, and that one is writable. Here is the part most people miss, and it is good news. Docker mounts every --tmpfs with nosuid, nodev and noexec already, and adding your own size=64m does not remove them. Docker merges the options you pass with those safe defaults. So a binary dropped in that /tmp gets "Permission denied" instead of handing an attacker a shell. The only way to punch a hole is to pass exec yourself, usually by copying a tmpfs line off the internet, or because some installer insists it has to run things from /tmp. Don't. Check what you actually got with grep ' /tmp ' /proc/mounts before you trust it. The real blind spot sits two lines further down your run command: the writable volume, which carries no noexec and will happily run whatever an attacker leaves there.

The paths read-only doesn't cover

Here is the gap attackers actually use. The --read-only flag governs the image layers and nothing else. Named volumes and bind mounts (a bind mount is a directory from the host machine plugged straight into the container) are separate mounts, and they stay writable unless you say otherwise. Worse, they are executable. A volume gets no noexec the way a tmpfs does, so an attacker who reaches a writable data directory can drop a binary there and run it, straight past your immutable rootfs. If that mount is a bind of a host path, they can now write to the host. If it is the Docker socket, the game is over: anyone who can write to /var/run/docker.sock can start a new privileged container and own the whole machine, and read-only does nothing about it. Treat every mount the way you treat the rootfs. If the app only reads a mount, append :ro so nothing can write to it. Detection fits on one line. List your mounts with their RW flag, and anything writable that didn't need to be is a finding.

detect / fix
# a normal write to the volume still works; --read-only never touched it
$ docker exec web sh -c 'echo cache > /var/lib/app/data && cat /var/lib/app/data'
cache
# same script, two mounts: the tmpfs blocks exec, the volume runs it
$ docker exec web sh -c 'printf "#!/bin/sh\necho ran\n" > /tmp/s; chmod +x /tmp/s; /tmp/s'
sh: /tmp/s: Permission denied # tmpfs is noexec: dead end
$ docker exec web sh -c 'printf "#!/bin/sh\necho ran\n" > /var/lib/app/s; chmod +x /var/lib/app/s; /var/lib/app/s'
ran # volume has no noexec: it executes
# audit every mount's writability
$ docker inspect --format \
'{{ range .Mounts }}{{ .Destination }} rw={{ .RW }}{{ println }}{{ end }}' web
/var/lib/app rw=true
# fix: if the app only reads it, mount the volume read-only
$ docker run -d --name web --read-only -v webdata:/var/lib/app:ro alpine:3.20 sleep infinity
$ docker exec web sh -c 'echo x > /var/lib/app/x' 2>&1
sh: can't create /var/lib/app/x: Read-only file system
A process writes to a path. What happens?
write() to a path in the container
which mount backs that path?
path from the image (/usr, /etc, the app)
Blocked
EROFS 'Read-only file system'; nothing persists and the attempt is an alert
declared tmpfs (/tmp, /run)
Writable, non-exec
RAM-backed and wiped on stop; Docker mounts it noexec,nosuid,nodev by default, so a dropped binary won't run
named volume or bind (/var/lib/app)
Writable AND exec
read-only doesn't touch it and it has no noexec; an attacker can persist here and run it. Add :ro or lock it down
docker.sock or a host path mounted in
Game over
immutable rootfs is irrelevant; the mount itself is the escape
Read-only governs the image layers and nothing else. A tmpfs comes noexec by default; a volume or bind does not. Every mount you add is yours to lock down.

Read-only rootfs is cheap enough to require, not hope for. Kill one myth first. The Restricted Pod Security Standard in Kubernetes (the strictest of the three built-in pod security levels) does not switch it on for you, so don't assume the platform has your back. Set securityContext.readOnlyRootFilesystem: true on your pods yourself, and block anything that ships without it with a policy engine like Kyverno or OPA Gatekeeper (OPA = Open Policy Agent, a rules engine that says yes or no to a config before it ever reaches the cluster). That is the same check the CIS Docker Benchmark makes in control 5.12 (CIS = Center for Internet Security, which publishes hardening checklists). Stack read-only with a non-root user and dropped Linux capabilities (the individual root powers you can hand back one by one), and an attacker who lands inside has no writable binaries and nowhere to stage a payload that survives a restart. That is a lot of persistence gone for the price of one boolean.

Malware expects to write. It reaches for /usr or /var/tmp, and on an immutable rootfs it gets an error instead. Anything it does manage to write has to land in the tmpfs and volumes you allowed on purpose, and those are exactly the places you already size and watch. A quiet compromise becomes a noisy one.

Some apps insist on writing next to their own binary, usually because nobody ever told them not to. The fix is a clean split: code on the read-only layers, state on a mounted volume. Change the app, not the control. Switching read-only off because one log file sits in the wrong directory trades a whole class of protection for the five minutes of work you were avoiding.

Pair it with a non-root user. Root on a read-only rootfs can still do real damage through mounts and capabilities, so this is not a force field. What it removes is the casual persistence: the dropped binary, the edited startup script, the cron entry (cron = the Linux job scheduler that runs commands on a timer) that quietly brings the attacker back tomorrow.

Run the check again after every change window. Confirm the flag is still set, paste the docker inspect line and its output into the ticket, and refuse to close the change if the reading drifted from what you expected. Pick the tightest set of writable paths the workload can actually live with. On one container that is housekeeping. Across every host and every pipeline it is the difference between a fleet you can vouch for and one you hope is fine.

Try this

Prove both halves yourself in about thirty seconds. Start a container with --read-only and watch a write to / fail, then add a tmpfs on /tmp and watch the path you approved work.

terminal
$ docker run --rm --read-only alpine sh -c 'touch /x 2>&1; echo status:$?'
touch: /x: Read-only file system
status:1
$ docker run --rm --read-only --tmpfs /tmp alpine sh -c 'touch /tmp/x && echo tmp-ok'
tmp-ok

Takeaway

Make the root filesystem immutable, then open writable paths only where the app genuinely has to keep something. Treat the failed writes as a feature. They turn silent persistence into an error you can see, log and page on.

Quick check
01You harden a container with --read-only and --tmpfs /tmp:size=64m, and you mount a named volume at /var/lib/app for the app's cache. An attacker with a shell writes the same small script into both places and marks each one executable with chmod +x. The copy in /var/lib/app runs. The copy in /tmp is denied. What explains the difference?
Correct — Docker always applies nosuid, nodev and noexec to a tmpfs, and passing size keeps them, so the /tmp copy gets Permission denied. Volumes and bind mounts have no noexec and read-only ignores them, which is why the volume is the real launch pad. Mount it :ro, or mount it noexec.
Incorrect — This is the myth doing the rounds. size= does not remove noexec. Run grep ' /tmp ' /proc/mounts and you will still see nosuid,nodev,noexec. You lose noexec only by passing exec on purpose.
Incorrect — --read-only covers the image layers, not the tmpfs, and it is about writing rather than executing. The /tmp denial comes from noexec, a separate tmpfs default that Docker sets for you.
Incorrect — A named volume is its own filesystem mounted over that path, so it inherits nothing from the layer beneath. It runs the script because Docker never sets noexec on volumes, only on a tmpfs.
02You want to prove a container's root filesystem is really immutable. docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' says true, and grep ' / ' /proc/mounts inside the container shows the overlay mount tagged ro. What does the /proc/mounts check tell you that the docker inspect line does not?
Incorrect — They answer different questions. inspect reads the config Docker was handed; /proc/mounts is the kernel's live state right now.
Incorrect — inspect can list mounts perfectly well. The point of checking the root mount is proving enforcement, not enumerating mounts.
Correct — The ro token on the live overlay mount shows the kernel enforcing immutability. inspect only reports what was asked for.
Incorrect — /proc does come straight from the kernel, but the reason to run this check is enforcement versus declared config, not daemon uptime.
03You add --read-only to your nginx container and it now dies at startup, logging that it cannot write /var/cache/nginx and /var/run. What is the right fix?
Correct — Run read-only, read the errors it throws, and hand back only the paths that need writing. Nginx boots and the rest of the rootfs stays immutable.
Incorrect — Nginx runs fine read-only once you give it its scratch paths. Dropping the flag throws away the whole protection to fix two directories.
Incorrect — The block is the read-only rootfs, not file permissions. Even root gets EROFS on an image path, and running as root hands an attacker more to work with.
Incorrect — Nginx needs writable cache and run directories, not an executable /tmp. Passing exec strips a safe default and gives an attacker somewhere to run code.

Related