cap-drop ALL & no-new-privileges
Least privilege, actually enforced.
Start a container the plain way, docker run with nothing extra, and the process inside is handed 14 Linux capabilities. A capability is one slice of root's power, broken off and given out on its own. Your app probably uses one of the fourteen. Maybe two. One of them is CAP_NET_RAW, and that single capability lets a compromised container build network packets by hand: fake ARP replies (ARP, Address Resolution Protocol, is how machines on the same local network find each other) that pull a neighbour's traffic through you, or fake DNS answers (DNS, Domain Name System, the internet's phone book) that send it to a server the attacker owns. A quiet man in the middle on the same bridge network, run out of a container that had no business touching raw packets. You never asked for it. The image never needed it. It is on because the default set is generous and nobody trimmed it.
Root used to be all or nothing. Either you were UID 0 (user ID zero, the superuser account) and held every privilege the kernel has, or you held none of them. Capabilities took that one god-key and cut it into roughly 40 separate keys on a ring. CAP_CHOWN is the key for changing who owns a file. CAP_NET_BIND_SERVICE opens ports below 1024. CAP_SYS_ADMIN is a fat master key that opens dozens of privileged operations on its own. Docker clips 14 of these keys onto every fresh container before handing it over. A plain stateless service needs none of them.
First, count the keys you are carrying
Look before you cut anything. The kernel publishes every process's capability sets in /proc/<pid>/status as hexadecimal bitmasks (proc is the kernel's process filesystem, a directory of fake files that report live kernel state, and <pid> is any process ID). CapEff is what the process can use right now. CapBnd is the ceiling it can never rise above. NoNewPrivs is the flag the second half of this lesson is about. Reading those three lines from inside a running container is the exact recon an attacker does in the first minute after landing, so run it before they do.
# inside the container: what capabilities and flags am I actually running with?$ docker run --rm alpine grep -E 'CapEff|CapBnd|NoNewPrivs' /proc/self/statusCapEff: 00000000a80425fb # active caps, as a bitmaskCapBnd: 00000000a80425fb # the ceiling: can never exceed thisNoNewPrivs: 0 # setuid escalation is still possible# decode that mask into human names$ docker run --rm alpine sh -c 'apk add -q libcap; capsh --decode=00000000a80425fb'0x00000000a80425fb=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap# audit a running container from the host, no shell needed$ docker inspect --format 'drop={{.HostConfig.CapDrop}} opt={{.HostConfig.SecurityOpt}}' webdrop=[] opt=[] # nothing dropped, no-new-privileges off, so this is the gap
That mask, 00000000a80425fb, is Docker's default 14, and it reads the same on every stock container you have ever started. capsh --decode turns the hex back into names so you can see what you are carrying. The docker inspect line runs the same audit from the host without a shell inside the container, which is how you check a whole fleet, or a CI job (CI, continuous integration, the pipeline that builds and tests your code automatically), without logging into each one by hand.
Drop to zero, then add back by name
The method is mechanical, which is exactly what makes it reliable. Drop every capability. Run the app. When something fails with EPERM (the kernel's shorthand for operation not permitted), read which operation got refused and add that one capability back by name. Do not try to guess the list up front. The failures hand you the exact minimum for free. Here is what that looks like against a container that still runs as root and holds no capabilities at all.
# still UID 0 inside the container, but every capability is gone$ docker run --rm --cap-drop=ALL alpine sh -c 'touch /tmp/f && chown 99:99 /tmp/f'chown: /tmp/f: Operation not permitted # blocked: this needs CAP_CHOWN$ docker run --rm --cap-drop=ALL alpine ping -c1 1.1.1.1PING 1.1.1.1 (1.1.1.1): 56 data bytesping: permission denied (are you root?) # blocked: this needs CAP_NET_RAW# read the failure, add back ONLY that one capability, by name$ docker run --rm --cap-drop=ALL --cap-add=CHOWN alpine sh -c 'chown 99:99 /tmp && echo ok'ok$ docker run --rm --cap-drop=ALL --cap-add=NET_RAW alpine ping -c1 1.1.1.164 bytes from 1.1.1.1: seq=0 ttl=57 time=8.5 ms
Read that output again. The container is still root, and it still cannot change the owner of a file or open a raw socket. That is the whole trick. The kernel checks capabilities against the operation, not against your user ID, so root with an empty keyring is a far smaller target than the word root suggests. Most services never hit a single EPERM after the drop. The two exceptions you will actually meet are a port below 1024 (add CAP_NET_BIND_SERVICE, or bind a high port and let the proxy in front map it) and, once in a while, a genuine need to ping. If an app asks for CAP_SYS_ADMIN, that is a design problem to go chase down, not a flag to paste in.
no-new-privileges bolts the setuid door
Some binaries carry a setuid bit (setuid, set user ID on execution, is a single permission bit on the file). It works like a visitor badge that is good for one errand. Run passwd or su as an ordinary user and the kernel promotes you to whoever owns the file, usually root, for the length of that one program, so it can finish a job your account could not. Attackers love setuid binaries for the same reason. A buggy setuid-root program is a short walk from an unprivileged shell to full root. no-new-privileges confiscates the badge at the door. Once the flag is set, running a setuid binary no longer raises privileges. Not a little. Not at all.
# a setuid-root helper that stands in for su, sudo, mount, or any setuid binaryFROM alpine:3.20 AS buildRUN apk add --no-cache gcc musl-devRUN printf '#include <stdio.h>\n#include <unistd.h>\nint main(){setuid(0);printf("euid=%%d\\n",geteuid());}' > s.c \&& gcc -static -o /helper s.cFROM alpine:3.20COPY --from=build /helper /usr/local/bin/helperRUN chmod 4755 /usr/local/bin/helper # setuid bit set, file owned by rootUSER 10001:10001 # run as an unprivileged user
$ docker build -q -t suid ./suidsha256:6f1c9b3a2d84...# no-new-privileges OFF: the setuid bit fires, euid jumps to 0, a full escalation$ docker run --rm suid helpereuid=0# no-new-privileges ON: the kernel ignores the setuid bit, the uid stays put$ docker run --rm --security-opt no-new-privileges suid helpereuid=10001
Underneath, that flag is a single kernel bit, set with prctl(PR_SET_NO_NEW_PRIVS) (prctl is the process control syscall, the call a process uses to change its own settings). Once the bit is on it can never be turned back off, and every child process inherits it, so nothing the container starts later can climb back out through a setuid binary. It costs nothing at runtime and closes a whole class of escalation, which is why it belongs on essentially every workload, right beside the capability drop. Prove both landed, from the same place you looked at the start.
# the end state you actually want, proven from /proc$ docker run --rm --cap-drop=ALL --security-opt no-new-privileges alpine \grep -E 'CapBnd|NoNewPrivs' /proc/self/statusCapBnd: 0000000000000000 # zero capabilities, even for rootNoNewPrivs: 1 # setuid door bolted shut# and audited from the host, which is what a CI or admission check asserts$ docker inspect --format 'drop={{.HostConfig.CapDrop}} opt={{.HostConfig.SecurityOpt}}' hardeneddrop=[ALL] opt=[no-new-privileges]
cap-drop ALL plus no-new-privileges is the least-privilege pair, and it belongs in the compose snippet you copy into every new service, not in a hardening ticket for later. Each half covers a gap the other one leaves open. no-new-privileges stops a setuid binary from escalating even if one slips into the image on a base image bump nobody read closely.
Add capabilities back one at a time, and only when you have a failure message to point at. NET_BIND_SERVICE for a low port is the one that comes up often. Almost nothing else should ever be routine. Write each exception down next to the name of the person who owns the service, so the next reader can tell it was a decision rather than an accident.
Your orchestrator needs the same defaults. A carefully hardened Dockerfile counts for very little if the thing that actually launches the container hands back privileged mode or the full capability set at runtime.
In production this doubles as a post-change check. After a change window, run the same docker inspect line, paste the command and its output into the ticket, and refuse to close the change if the reading drifted. A control nobody re-reads quietly stops being a control.
Rolling this out across services you did not write is a canary job, not a big-bang one. Pick one low-traffic service, drop everything, and watch its logs for permission denied lines across a full day, including the code paths that only run on a nightly job. Batch work is where the missing capability hides, because nobody exercises it during a deploy.
Keep the tightest scope the workload can actually run under, and let that be the boring default. One container with a trimmed keyring is a small win. The same default applied to every host, every pipeline and every new service somebody spins up next quarter is what really shrinks what an attacker can do after the first mistake.
Try this
Drop everything, then go find the first thing that breaks. Ping needs a raw socket. Binding a low port needs its own key. Read the CapEff line before and after, add back the single capability that fixes it, and watch exactly one bit in the mask flip on.
$ docker run --rm --cap-drop ALL alpine sh -c 'grep CapEff /proc/1/status'CapEff: 0000000000000000$ docker run --rm --cap-drop ALL --cap-add NET_BIND_SERVICE alpine sh -c 'grep CapEff /proc/1/status'CapEff: 0000000000000400$ docker run --rm --cap-drop ALL --security-opt no-new-privileges:true alpine iduid=0(root) gid=0(root) ...
Takeaway
Make cap-drop ALL and no-new-privileges your starting point, then add back only what the process can prove, with a real error message, that it needs. Every exception belongs in a review ticket someone can find later, not in one engineer's memory.
--cap-drop=ALL and no --user flag, so the process inside is UID 0. Why is a compromise still meaningfully contained?chmod 4755, owned by root) and then sets USER 10001. Running docker run --rm suid helper prints euid=0. What does docker run --rm --security-opt no-new-privileges suid helper print, and why?