Docker & ContainersII Β· Linux InternalsCapabilities
Linux capabilities β fine-grained root
What you'll learn
- Explain why root binary is wrong for security decisions
- List the capabilities that matter for containers
- Drop capabilities in production using --cap-drop
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-09
The traditional Unix βrootβ is binary: you are either UID 0 or you are not. Real security decisions are finer. Linux divides rootβs powers into ~40 distinct capabilities that can be granted or denied independently.
A container running as UID 0 with most capabilities dropped is much safer than a container running as a non-root user with all capabilities. The capability model is how this works.
Why βroot or notβ is the wrong model
A non-root user can be denied a few things: bind to ports below 1024, mount filesystems, load kernel modules, override file permissions. A root user can do all of those, plus anything else.
When a vulnerability lands in a non-root process, the attacker is limited to the non-root set. When the same vulnerability lands in a root process, the attacker is one kernel exploit away from owning the host.
The capability model splits βrootβ into independent capabilities.
A process can have CAP_NET_BIND_SERVICE (to bind port 80) without
having CAP_SYS_ADMIN (to mount filesystems).
The capabilities that matter
Linux defines ~40 capabilities. The relevant ones for Docker:
| Capability | What it grants |
|---|---|
CAP_CHOWN | Change file ownership |
CAP_DAC_OVERRIDE | Bypass file permission checks |
CAP_FOWNER | Bypass permission checks based on file owner |
CAP_FSETID | Preserve SUID/SGID bits across privilege changes |
CAP_KILL | Send signals to processes not owned by the user |
CAP_NET_BIND_SERVICE | Bind to privileged ports (below 1024) |
CAP_NET_RAW | Use RAW sockets (needed for ping, tcpdump) |
CAP_SETFCAP | Set file capabilities |
CAP_SETGID | Change GID |
CAP_SETUID | Change UID |
CAP_SYS_ADMIN | A catch-all: mount, swapon, setns, etc. |
CAP_SYS_PTRACE | Trace other processes |
CAP_SYS_RESOURCE | Override resource limits |
CAP_SYS_ADMIN is the most dangerous. If you can drop it without
breaking the workload, do so.
Dropping capabilities in practice
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginxdocker run --cap-drop=SYS_ADMIN --cap-drop=SYS_PTRACE --cap-drop=NET_RAW nginxPID=$(docker inspect --format '{{.State.Pid}}' CONTAINER)
cat /proc/$PID/status | grep -E '^Cap'The output is five hex numbers, one per capability set:
- CapInh (Inheritable): capabilities preserved across
execvewhen the executed file also lists them as inheritable. - CapPrm (Permitted): the maximum set the process can claim.
- CapEff (Effective): the set currently active β the one the kernel actually checks.
- CapBnd (Bounding): the upper bound that any process in this hierarchy can ever claim.
- CapAmb (Ambient): the set that survives
execveof an unprivileged file. Docker leaves this empty, and the next section is entirely about the consequence.
The trap: --cap-add plus a non-root USER
CONTAINER=web
PID=$(docker inspect --format '{{.State.Pid}}' "$CONTAINER")
grep -E '^(Name|Uid|Cap)' "/proc/$PID/status"Name: prometheus
Uid: 65534 65534 65534 65534
CapInh: 0000000000000000
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: 00000000a80425fb
CapAmb: 0000000000000000That capture is from a real non-root container on a stock Engine
host with the default capability set. CapBnd holds the fourteen
default capabilities; CapPrm and CapEff hold nothing. Run the
same command against a container whose entrypoint runs as UID 0 and
CapPrm and CapEff both read 00000000a80425fb β identical to
CapBnd. That difference is the whole failure mode, and it is
visible in three lines of /proc.
There are three real fixes, in order of preference:
- Listen above 1024 and remap the port.
-p 80:8080publishes host port 80 to a container port that needs no capability at all. This removes the requirement instead of satisfying it, and it is the option that survives the next base-image change. - Lower the privileged-port boundary for that container.
docker run --sysctl net.ipv4.ip_unprivileged_port_start=80makes port 80 unprivileged inside the container network namespace only. The sysctl is namespaced, so it does not touch the host. - Give the binary a file capability at build time.
RUN setcap cap_net_bind_service=+ep /usr/local/bin/appsetsF(permitted), which makes theF(permitted) & P(bounding)term non-zero and the capability lands. Re-runsetcapon every rebuild β it is file metadata, and aCOPYof a new binary drops it.
Note what is not on that list: reverting to USER root. Running as
UID 0 does make the capability effective, and it also hands every
other default capability to the same process. The failure is telling
you the capability model works; do not disable it to make the error
message go away.
Capabilities vs user namespaces
User namespaces and capabilities are complementary:
- Capabilities restrict what a process can do regardless of UID.
- User namespaces map UIDs inside the container to different UIDs on the host. UID 0 inside the container is UID 100000 on the host.
With user namespaces, the containerβs βrootβ is unprivileged on the host. Combined with dropped capabilities, the container is much safer. See the lesson βUser namespaces and rootless Dockerβ in this part.
Best-practice workflow
- **Start from
--cap-drop=ALL.** Every capability is opt-in. - Add only what the workload needs. Read the application docs. Run the workload locally without root and see what fails. The failures tell you which capabilities are required.
- Test that the workload still works. A container that drops the wrong capability fails silently or noisily. Verify the actual workload still functions.
- Document the capability list. Record the rationale for each retained capability. Make it auditable.
- Re-test after every dependency upgrade. A new library version may require a capability you previously dropped.
Knowledge check
Knowledge check Β· 5 questions
Q1. Which capability allows binding to TCP port 80?
Q2. A non-root container is always safer than a root container.
Q3. What does `--cap-drop=ALL` achieve?
Q4. A container built with `USER 1001` is run with `--cap-drop=ALL --cap-add=NET_BIND_SERVICE`. It fails to bind port 80 with EACCES. `/proc/<pid>/status` shows CapBnd set and CapEff zero. What is wrong?
Q5. Which of these actually let a non-root container listen on port 80? Select all that apply.
Passing score: 75%. Answers are checked in this browser.