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

  1. You run docker run or docker compose up with capability settings.
  2. The Docker client sends the create-container request to the Docker daemon.
  3. The daemon resolves the image tag, pulls missing read-only layers if needed, and creates a container with its own writable layer.
  4. 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.
  5. --cap-drop removes capabilities from the set Docker would otherwise grant. --cap-add adds named capabilities to that set.
  6. 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.
  7. If no-new-privileges is enabled, the process and its children cannot gain extra privileges through setuid executables or file capabilities.
  8. 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 ALL for services that can tolerate it, then add back only proven requirements.
  • Prefer high application ports such as 8080 inside containers when possible, then publish the desired host port with -p or Compose ports.
  • Avoid --privileged for 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 USER or --user, read-only filesystems, minimal mounts, resource limits, seccomp, and secret hygiene.
  • Use specific image tags such as nginx:1.27-alpine and alpine:3.20; latest is 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 inspect when 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

  1. Run alpine:3.20 once with default capabilities and once with --cap-drop ALL. Expected end state: compare the CapEff lines from /proc/self/status.
  2. Create a Compose file for nginx:1.27-alpine that publishes host port 8080, drops all capabilities, adds NET_BIND_SERVICE, and enables no-new-privileges.
  3. Inspect a container you already use and write down its CapDrop and CapAdd values. Hint: use docker inspect with 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 ALL plus narrow --cap-add entries is the usual least-privilege pattern.
  • --privileged is 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.
  • EXPOSE does not publish ports; use -p or Compose ports:.