Limiting Container Capabilities
Docker capabilities are small pieces of Linux root privilege that can be granted or removed from a container process. They matter because a container may run as root inside its namespace, but most applications do not need every kernel-level power that root traditionally has.
Limiting capabilities is a practical least-privilege control. If an application is compromised, dropped capabilities can stop it from changing network settings, creating device files, loading sensitive tracing tools, or performing other operations unrelated to the app’s real job.
Overview: How Capabilities Work
Linux used to treat root as one giant permission level. Capabilities split that privilege into named units such as CAP_NET_BIND_SERVICE, CAP_NET_RAW, CAP_CHOWN, CAP_SETUID, CAP_SYS_CHROOT, and the especially broad CAP_SYS_ADMIN. A process can have some capabilities without having all of root’s traditional power.
Docker containers are ordinary Linux processes isolated with namespaces, cgroups, mounts, networking rules, seccomp filters, and optional AppArmor or SELinux policy. An image is a read-only template made from filesystem layers and metadata. A container is an instance of that image with a thin writable layer plus runtime configuration. Capabilities belong to that runtime configuration; changing them does not rebuild or change the image.
By default, Docker grants a limited set of capabilities to Linux containers. It does not grant every Linux capability, and it does not grant the huge access level of --privileged. The default set is designed for common application behavior: changing ownership inside the container, switching users, binding low ports, sending raw network packets, and similar operations. Even that default set is often larger than a simple web app, worker, or cron-like job needs.
The safest common pattern is to start with --cap-drop ALL, then add back only a capability that the application has proven it needs. For example, a container that must bind to a privileged port on systems where low ports are restricted may need NET_BIND_SERVICE. A normal HTTP app listening on port 8080, a queue worker, or a static file server usually needs no extra capability at all.
Capabilities are Linux-specific. Docker Desktop on macOS and Windows runs Linux containers inside a Linux virtual machine, so the capability rules apply inside that VM. Windows containers use a different security model. Also remember that capabilities are only one layer: a dangerous bind mount, exposed Docker socket, root user, writable root filesystem, or vulnerable kernel-facing feature can still create risk.
Syntax
docker run [OPTIONS] IMAGE[:TAG] [COMMAND] [ARG...]
| Option | Purpose |
|---|---|
--cap-drop CAP |
Removes one Linux capability from the container’s permitted set. |
--cap-drop ALL |
Removes all capabilities. This is the strongest starting point for least privilege. |
--cap-add CAP |
Adds one capability back. Docker accepts names such as NET_BIND_SERVICE and also forms such as CAP_NET_BIND_SERVICE. |
--privileged |
Grants broad host-facing permissions and disables many normal restrictions. It should not be used for normal apps. |
--security-opt no-new-privileges |
Prevents the process from gaining additional privilege through setuid binaries or file capabilities. |
--user UID:GID |
Runs the process as a non-root user. This complements capability limits. |
Compose uses service-level keys:
services:
SERVICE_NAME:
image: IMAGE:TAG
cap_drop:
- ALL
cap_add:
- CAPABILITY_NAME
Put Docker runtime flags before the image name. Anything after the image name is the command that runs inside the container, not a Docker option.
Examples
Example 1: Compare Default Capabilities with Dropped Capabilities
docker run --rm alpine:3.20 sh -c 'grep CapEff /proc/self/status'
docker run --rm --cap-drop ALL alpine:3.20 sh -c 'grep CapEff /proc/self/status'
Output:
CapEff: 00000000a80425fb
CapEff: 0000000000000000
/proc/self/status exposes the current process capability masks. The exact hexadecimal value for the default container can vary by Docker version, host kernel, and runtime configuration, but the idea is stable: the first process has effective capabilities and the second has none. The image layers are identical; only the runtime permission set changed.
Example 2: Start a Web Server with a Minimal Capability Set
docker run -d --name cap-nginx \
--cap-drop ALL \
--cap-add NET_BIND_SERVICE \
--security-opt no-new-privileges \
-p 8080:80 \
nginx:1.27-alpine
Output:
6bb2f2a64c4f1f8b73a2c2d49c0e2b911e18d2ce7a7294ff7d6fc4fdb0e3a111
This container starts from a pinned image tag, drops every capability, then adds back NET_BIND_SERVICE. That capability allows binding to low-numbered ports where the kernel requires it. The -p 8080:80 flag publishes host port 8080 to container port 80; a Dockerfile EXPOSE instruction would only document the port and would not publish it.
Inspect the recorded runtime settings and clean up:
docker inspect cap-nginx --format 'CapDrop={{json .HostConfig.CapDrop}} CapAdd={{json .HostConfig.CapAdd}}'
docker rm -f cap-nginx
Output:
CapDrop=["ALL"] CapAdd=["NET_BIND_SERVICE"]
cap-nginx
docker inspect confirms that capabilities are stored in the container’s host configuration. Removing the container removes that runtime configuration, but the nginx:1.27-alpine image remains available for future containers with different settings.
Example 3: Record Capability Limits in Compose
services:
web:
image: nginx:1.27-alpine
ports:
- "8080:80"
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
security_opt:
- no-new-privileges:true
Start the service with the modern Compose command:
docker compose up -d
Output:
[+] Running 1/1
- Container app-web-1 Started
Compose makes the security setting repeatable for a project instead of leaving it in terminal history. The old standalone docker-compose command exists in legacy environments, but this course uses the built-in docker compose CLI plugin.
How It Works Step by Step
- You run
docker runordocker compose upwith capability settings. - The Docker client sends the create-container request to the Docker daemon.
- The daemon resolves the image tag, pulls missing read-only layers if needed, and creates a container with its own writable layer.
- Docker prepares namespaces, mounts, networking, cgroups, seccomp policy, and the process user. Capabilities are attached to the process credentials for the container’s initial process.
--cap-dropremoves capabilities from the set Docker would otherwise grant.--cap-addadds named capabilities to that set.- The container process starts. When it tries a privileged operation, the Linux kernel checks the relevant capability. If the process lacks it, the operation fails with a permission error.
- If
no-new-privilegesis enabled, the process and its children cannot gain extra privileges through setuid executables or file capabilities. - When the container stops and is removed, the runtime capability configuration disappears with that container. The image itself is unchanged.
This is why capabilities work well with immutable images. You can run the same image with broader permissions in a lab, narrower permissions in production, and different settings for different services without rebuilding the image.
Common Mistakes
Using Privileged Mode as a Shortcut
docker run --rm --privileged nginx:1.27-alpine
This is wrong for a normal web server because --privileged grants broad access that the app does not need. Use a small capability set instead:
docker run --rm --cap-drop ALL --cap-add NET_BIND_SERVICE nginx:1.27-alpine nginx -t
Adding CAP_SYS_ADMIN Casually
docker run --rm --cap-add SYS_ADMIN alpine:3.20 sh
SYS_ADMIN is extremely broad and is often described as a catch-all capability. If an app seems to need it, pause and identify the exact operation failing. The right fix is often a different mount, a narrower capability, or a design change that avoids kernel administration from inside the app container.
Dropping Capabilities but Keeping Dangerous Mounts
docker run --rm --cap-drop ALL -v /var/run/docker.sock:/var/run/docker.sock alpine:3.20 sh
This still exposes the Docker daemon socket. A process that can control the daemon can often start new containers, mount host paths, and gain host-level access. Capability limits do not make the Docker socket safe to mount into an untrusted container.
Forgetting That EXPOSE Is Only Metadata
FROM nginx:1.27-alpine
EXPOSE 80
EXPOSE documents the intended container port in image metadata. It does not publish that port to the host and it does not grant network capabilities. Use docker run -p 8080:80 nginx:1.27-alpine or Compose ports: when host access is required.
Best Practices
- Start with
--cap-drop ALLfor services that can tolerate it, then add back only proven requirements. - Prefer high application ports such as
8080inside containers when possible, then publish the desired host port with-por Composeports. - Avoid
--privilegedfor application containers. Reserve it for tightly controlled low-level administration cases. - Treat
SYS_ADMIN,NET_ADMIN,SYS_PTRACE, and device-related permissions as high-risk and require a clear reason. - Combine capability limits with
USERor--user, read-only filesystems, minimal mounts, resource limits, seccomp, and secret hygiene. - Use specific image tags such as
nginx:1.27-alpineandalpine:3.20;latestis a moving target and weakens reproducibility. - Record capability settings in Compose or deployment configuration so they are reviewed and repeatable.
- Inspect running containers with
docker inspectwhen debugging permission behavior. - Do not mount the Docker socket into general-purpose app containers. Capabilities cannot compensate for daemon access.
- Test the full application path after dropping capabilities: startup, logging, DNS, health checks, TLS, uploads, and shutdown hooks.
Practice Exercises
- Run
alpine:3.20once with default capabilities and once with--cap-drop ALL. Expected end state: compare theCapEfflines from/proc/self/status. - Create a Compose file for
nginx:1.27-alpinethat publishes host port8080, drops all capabilities, addsNET_BIND_SERVICE, and enablesno-new-privileges. - Inspect a container you already use and write down its
CapDropandCapAddvalues. Hint: usedocker inspectwith a Go template format string.
Summary
- Linux capabilities split root privilege into smaller permissions that Docker can add or remove per container.
- Capabilities are runtime settings on containers, not image layers.
--cap-drop ALLplus narrow--cap-addentries is the usual least-privilege pattern.--privilegedis much broader than adding one capability and should not be a normal application default.- Capability limits work best alongside non-root users, read-only filesystems, careful mounts, seccomp, resource limits, and pinned images.
EXPOSEdoes not publish ports; use-por Composeports:.
