Docker Read-Only Containers and Capabilities
Harden a real nginx container to a read-only filesystem and four capabilities - including the two failed attempts, because the failures are where the learning is. Every capability bitmask here was read out of a running container.
Security and Production Guide 40 of 46 Advanced
- OSUbuntu 26.04 LTS (resolute)
- Docker Engine29.7.2
- Buildxv0.36.1
- Architectureamd64
- TimeAbout 16 min
- Reviewed22 August 2026
Tested on the versions above. Capability bitmasks depend on the kernel and the daemon's default set. The method transfers; the exact hex will differ.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| DOCKER01 | 192.168.0.21 | Ubuntu 26.04 LTS | Docker Host | 2 Core | 4 GB | 50 GB |
Before you start
- You understand the root-by-default problem - guide 39 in this path.
- Comfortable reading container state - guide 7 in this path.
-
Containers run as root unless you say otherwise
By default the process inside is uid 0, and it holds a substantial default capability set. It is not the same as host root - namespaces and the default seccomp and AppArmor profiles constrain it - but it is far more privilege than an application needs.
bash docker run --rm alpine:3.22 iduid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel)docker run --rm --user 1000:1000 alpine:3.22 iduid=1000 gid=1000 groups=1000Expected resultRoot by default; an unprivileged uid when asked.
Success conditionYou have seen the default.
--useris the single cheapest hardening step available. -
Measure the capability set
Capabilities are the granular pieces of root's power.
CapEffin/proc/self/statusis the effective set as a bitmask, and it is the honest way to check what a container really holds - flags indocker inspectdescribe intent, this describes reality.bash Example session docker run --rm alpine:3.22 sh -c "grep CapEff /proc/self/status"CapEff: 00000000a80425fbdocker run --rm --cap-drop ALL alpine:3.22 sh -c "grep CapEff /proc/self/status"CapEff: 0000000000000000Expected resultA populated bitmask by default, all zeroes after dropping everything.
Success conditionYou can prove capabilities were dropped rather than trusting the flag. Zero means the process has none at all.
-
Drop everything, then add back only what breaks
This is the method: start from nothing and let the failures tell you what is genuinely required. Here
chownfails without CAP_CHOWN and succeeds when it is added back. Note thatpingstill works with no capabilities at all - modern kernels allow unprivileged ICMP sockets, so the old advice about NET_RAW is often wrong.bash docker run --rm --cap-drop ALL alpine:3.22 sh -c "chown nobody /tmp && echo chown ok"chown: /tmp: Operation not permitteddocker run --rm --cap-drop ALL --cap-add CHOWN alpine:3.22 sh -c "chown nobody /tmp && echo chown ok with CHOWN added"chown ok with CHOWN addedExpected resultchown refused then permitted; ping working regardless.
Verify it worked
bash docker run --rm --cap-drop ALL alpine:3.22 ping -c1 127.0.0.1PING 127.0.0.1 (127.0.0.1): 56 data bytes64 bytes from 127.0.0.1: seq=0 ttl=42 time=0.037 msSuccess conditionYou add capabilities in response to a real failure, not in advance. Test your assumptions - ping needed nothing here.
-
Stop privilege escalation outright
no-new-privilegesprevents a process gaining privileges it did not start with, which neutralises setuid binaries inside the image. It is one flag, it almost never breaks anything, and the kernel reports it plainly.bash docker run --rm alpine:3.22 sh -c "grep NoNewPrivs /proc/self/status"NoNewPrivs: 0docker run --rm --security-opt no-new-privileges alpine:3.22 sh -c "grep NoNewPrivs /proc/self/status"NoNewPrivs: 1Expected result0 by default, 1 with the option.
Success conditionThe kernel confirms it. There is rarely a reason not to set this.
-
Make the filesystem read-only
--read-onlymounts the container's root filesystem read-only, so an attacker who gets execution cannot install tools or modify the application. Anything that legitimately needs to write gets an explicit tmpfs - writable, in memory, and gone when the container stops.bash docker run --rm --read-only alpine:3.22 sh -c "touch /tmp/x && echo wrote"touch: /tmp/x: Read-only file systemdocker run --rm --read-only --tmpfs /tmp alpine:3.22 sh -c "touch /tmp/x && echo wrote to tmpfs ok"wrote to tmpfs okExpected resultThe write refused, then permitted only on the explicit tmpfs.
Success conditionYou can grant writes narrowly. Everything not listed stays read-only.
-
First attempt at a hardened nginx - and why it failed
Applying all of it at once to a real image usually does not work first time, and the error is precise if you read it. nginx needs CAP_CHOWN during startup to take ownership of its cache directory. Without it the container exits 1 before serving anything.
bash Example session docker run -d --name cg-h1 --read-only --tmpfs /var/cache/nginx --tmpfs /var/run --cap-drop ALL --cap-add NET_BIND_SERVICE --security-opt no-new-privileges -p 8099:80 nginx:alpinedocker ps -a --filter name=cg-h1 --format "{{.Status}}"Exited (1) 3 seconds agodocker logs cg-h1 | tail -22026/08/20 08:23:10 [emerg] 1#1: chown("/var/cache/nginx/client_temp", 101) failed (1: Operation not permitted)Expected resultAn immediate exit, and a log line naming the exact syscall that was refused.
Success conditionYou read the failure instead of loosening everything. It names chown, so CAP_CHOWN is what is missing.
-
Second attempt - running but serving nothing
A subtler failure worth seeing. Adding a tmpfs over
/etc/nginx/conf.dmounted an empty directory over the default site configuration, so nginx started happily with no server block. The container is Up, the logs look healthy, and every request is refused. A tmpfs HIDES whatever was at that path in the image.bash Example session docker ps -a --filter name=cg-h2 --format "{{.Status}}"Up 3 secondsdocker logs cg-h2 | tail -22026/08/20 08:23:13 [notice] 1#1: start worker processescurl -s -o /dev/null -w "%{http_code}" http://localhost:8099000Expected resultA healthy-looking container that answers nothing.
Success conditionYou have seen Up with clean logs mean nothing. Only mount tmpfs over paths that are meant to be empty and writable.
-
The version that works
Read-only root, four capabilities instead of the default set, no new privileges, and tmpfs only where nginx genuinely writes. It serves normally, and an attempt to modify the web root is refused by the kernel.
bash Example session docker run -d --name cg-h3 --read-only --tmpfs /var/cache/nginx --tmpfs /var/run --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE --security-opt no-new-privileges -p 8099:80 nginx:alpinedocker ps --filter name=cg-h3 --format "{{.Status}}"Up 3 secondscurl -s -o /dev/null -w "%{http_code}" http://localhost:8099200Expected resultHTTP 200, a much smaller capability mask, and writes to the web root refused.
Verify it worked
bash Example session docker exec cg-h3 sh -c "grep CapEff /proc/self/status; grep NoNewPrivs /proc/self/status"CapEff: 00000000000004c1NoNewPrivs: 1docker exec cg-h3 sh -c "touch /usr/share/nginx/html/x"touch: /usr/share/nginx/html/x: Read-only file systemSuccess conditionServing normally while an attacker with execution cannot alter the site. Compare 0x4c1 with the default 0xa80425fb - that is the reduction, measured.
-
Record it so it is not a command you have to remember
None of this is useful as a shell command someone typed once. Put it in the Compose file where it is reviewed, versioned and applied every time.
bash cat compose.yamlservices: web: image: nginx:alpine read_only: true tmpfs: [/var/cache/nginx, /var/run] cap_drop: [ALL] cap_add: [CHOWN, SETUID, SETGID, NET_BIND_SERVICE] security_opt: [no-new-privileges:true]docker rm -f cg-h3Expected resultThe same hardening expressed declaratively.
Success conditionThe configuration is in version control. Note Compose spells it
no-new-privileges:truewhere the CLI takes a bare flag.
Troubleshooting
The container exits immediately after adding --cap-drop ALL
Why: Something during startup needed a capability. The log almost always names the refused operation.
Fix:Read the log, identify the syscall, add that one capability back. Do not go back to the full default set.
bash docker logs NAME 2>&1 | grep -i "not permitted\|denied"Read-only breaks an application that seemed not to write anything
Why: Most software writes somewhere - pid files, sockets, caches, temporary uploads.
Fix:Find the paths, then grant each one a tmpfs or a volume. Run it writable once and watch what it touches.
bash docker diff NAME# lists every path the container has written to since it startedA tmpfs mount made configuration disappear
Why: Mounting over a directory hides the image's contents at that path completely.
Fix:Only tmpfs paths that should be empty. For a directory that has content AND needs writes, mount a volume seeded from the image, or write to a different path.
bash docker run --rm IMAGE ls /etc/nginx/conf.d# what you would be hiding--privileged makes it work, so it stays
Why:
--privilegeddisables essentially every protection at once, including the default seccomp and AppArmor profiles. It is a debugging tool that becomes permanent.Fix:Find the one capability or device actually required and grant only that. If a container genuinely needs privileged, isolate it and be explicit about why.
bash # audit for privileged containers - each one is a standing riskdocker ps -q | xargs -r docker inspect --format "{{.Name}} privileged={{.HostConfig.Privileged}}"