CertGrid CertGrid
Hands-on Lab·Docker

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

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.

One Docker host is all this guide needs. Nothing here depends on a second machine, and the hardware above is modest on purpose - a 2 core, 4 GB VM runs everything in this path.
Server NameIP AddressOSRolesCPURAMHDD
DOCKER01192.168.0.21Ubuntu 26.04 LTSDocker Host2 Core4 GB50 GB

Before you start

  1. 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=1000

    Expected resultRoot by default; an unprivileged uid when asked.

    Success conditionYou have seen the default. --user is the single cheapest hardening step available.

  2. Measure the capability set

    Capabilities are the granular pieces of root's power. CapEff in /proc/self/status is the effective set as a bitmask, and it is the honest way to check what a container really holds - flags in docker inspect describe 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:	0000000000000000

    Expected 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.

  3. 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 chown fails without CAP_CHOWN and succeeds when it is added back. Note that ping still 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 added

    Expected 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 ms

    Success conditionYou add capabilities in response to a real failure, not in advance. Test your assumptions - ping needed nothing here.

  4. Stop privilege escalation outright

    no-new-privileges prevents 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:	1

    Expected result0 by default, 1 with the option.

    Success conditionThe kernel confirms it. There is rarely a reason not to set this.

  5. Make the filesystem read-only

    --read-only mounts 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 ok

    Expected resultThe write refused, then permitted only on the explicit tmpfs.

    Success conditionYou can grant writes narrowly. Everything not listed stays read-only.

  6. 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.

  7. Second attempt - running but serving nothing

    A subtler failure worth seeing. Adding a tmpfs over /etc/nginx/conf.d mounted 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:8099000

    Expected 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.

  8. 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:8099200

    Expected 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 system

    Success conditionServing normally while an attacker with execution cannot alter the site. Compare 0x4c1 with the default 0xa80425fb - that is the reduction, measured.

  9. 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-h3

    Expected resultThe same hardening expressed declaratively.

    Success conditionThe configuration is in version control. Note Compose spells it no-new-privileges:true where the CLI takes a bare flag.

Troubleshooting

Official sources