Skip to main content
RunBook Academy

Docker & ContainersII Β· Linux InternalsCapabilities

Linux capabilities β€” fine-grained root

Intermediate⏱ ~24 min

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

Not yet marked complete on this device.

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:

CapabilityWhat it grants
CAP_CHOWNChange file ownership
CAP_DAC_OVERRIDEBypass file permission checks
CAP_FOWNERBypass permission checks based on file owner
CAP_FSETIDPreserve SUID/SGID bits across privilege changes
CAP_KILLSend signals to processes not owned by the user
CAP_NET_BIND_SERVICEBind to privileged ports (below 1024)
CAP_NET_RAWUse RAW sockets (needed for ping, tcpdump)
CAP_SETFCAPSet file capabilities
CAP_SETGIDChange GID
CAP_SETUIDChange UID
CAP_SYS_ADMINA catch-all: mount, swapon, setns, etc.
CAP_SYS_PTRACETrace other processes
CAP_SYS_RESOURCEOverride resource limits

CAP_SYS_ADMIN is the most dangerous. If you can drop it without breaking the workload, do so.

Dropping capabilities in practice

Configuration changedrop everything
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginx
Configuration changedrop only the dangerous ones
docker run --cap-drop=SYS_ADMIN --cap-drop=SYS_PTRACE --cap-drop=NET_RAW nginx
Read-only / Safeinspect capabilities
PID=$(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 execve when 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 execve of 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

Read-only / Safedoes the process actually hold the capability?
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:	0000000000000000

That 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:

  1. Listen above 1024 and remap the port. -p 80:8080 publishes 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.
  2. Lower the privileged-port boundary for that container. docker run --sysctl net.ipv4.ip_unprivileged_port_start=80 makes port 80 unprivileged inside the container network namespace only. The sysctl is namespaced, so it does not touch the host.
  3. Give the binary a file capability at build time. RUN setcap cap_net_bind_service=+ep /usr/local/bin/app sets F(permitted), which makes the F(permitted) & P(bounding) term non-zero and the capability lands. Re-run setcap on every rebuild β€” it is file metadata, and a COPY of 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

  1. **Start from --cap-drop=ALL.** Every capability is opt-in.
  2. 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.
  3. Test that the workload still works. A container that drops the wrong capability fails silently or noisily. Verify the actual workload still functions.
  4. Document the capability list. Record the rationale for each retained capability. Make it auditable.
  5. Re-test after every dependency upgrade. A new library version may require a capability you previously dropped.

Knowledge check

Knowledge check Β· 5 questions

  1. Q1. Which capability allows binding to TCP port 80?

  2. Q2. A non-root container is always safer than a root container.

  3. Q3. What does `--cap-drop=ALL` achieve?

  4. 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?

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