11. Security
Docker · 11. SecurityContainer security has one framing that makes the rest coherent:
A container is a security boundary made of kernel features. A VM is a security boundary made of hardware virtualization. The first is thinner.
That is not a reason to avoid containers. It is a reason to know which side of the boundary you are relying on, and to add the cheap defences — because most of them are genuinely cheap.
The threat model
Section titled “The threat model”Three quite different concerns get called “container security”, and they need different answers:
1. ESCAPE code in a container gets access to the host. Requires a kernel bug, a misconfiguration, or a mounted socket. Rare via bugs, common via config.
2. SUPPLY your image contains something you did not intend: CHAIN a vulnerable dependency, a backdoored base image, a leaked credential in a layer.
3. BLAST a compromised container reaches things it should RADIUS not: the database, the cloud metadata endpoint, other services on the network.Most real incidents are 2 and 3. Almost none are a novel kernel escape. Spend accordingly.
The socket, again
Section titled “The socket, again”Chapter 4 said it and it is worth the repetition, because it is the number one real-world container escape:
Access to
/var/run/docker.sockis root on the host.
docker run -v /:/host --privileged -it alpine chroot /host sh # you are now root on the machineWhich means:
- Adding a user to the
dockergroup is granting passwordless root. Treat that request exactly as you would asudorequest. - Mounting the socket into a container gives that container the host. This is extremely common — CI runners doing “Docker-in-Docker”, monitoring agents, Watchtower, Portainer, Traefik reading container labels. Every one of those containers is, effectively, root.
If a container genuinely needs to talk to Docker, the options in order of preference:
1. Don't. Use an API the workload actually needs instead. 2. A socket PROXY (e.g. tecnativa/docker-socket-proxy) that exposes only specific read-only endpoints — /containers/json for a monitoring agent, and nothing that can create anything. 3. Rootless Docker or Podman, so the socket is not root's. 4. A build-specific tool that needs no daemon: Kaniko, Buildah, buildkitd in its own pod.--privileged
Section titled “--privileged”There is a persistent belief that --privileged is “a bit more access”. It is not. In one flag:
--privileged = ALL capabilities granted + seccomp DISABLED + AppArmor/SELinux UNCONFINED + /dev fully exposed (including raw disks) + /sys and /proc mounted read-WRITE + cgroups writableWith SYS_ADMIN and a writable /sys/fs/cgroup, escaping to the host is a short, well-documented sequence — you can register a cgroup release agent that the kernel executes on the host. There are working one-liners for it in every container security talk.
A privileged container is a host process with a different filesystem view. If you need one capability, add that capability:
--cap-add NET_ADMIN instead of --privileged for tcpdump/iptables --cap-add SYS_PTRACE instead of --privileged for strace/gdb --device /dev/ttyUSB0 instead of --privileged for one device --security-opt seccomp=unconfined instead of --privileged when a syscall is blockedReal needs for full --privileged are rare: Docker-in-Docker, some storage and networking daemons, and hardware-adjacent tooling.
The cheap hardening set
Section titled “The cheap hardening set”These take five minutes and remove most of concern 3.
USER 1000:1000 don't run as root. In the Dockerfile, --user 1000:1000 or at runtime.
--read-only the root filesystem cannot be modified --tmpfs /tmp …with a writable /tmp for scratch
--cap-drop ALL start from nothing --cap-add NET_BIND_SERVICE add back only what is needed (often: nothing at all)
--security-opt no-new-privileges a process can never gain privileges via setuid binaries. Blocks a whole class of escalation.
--pids-limit 200 a fork bomb cannot take the host down
--memory 512m --cpus 1 a runaway cannot starve neighboursTwo notes on USER. First, root in a container is not root on the host (chapter 2’s capability set) — but it is one bug away, and it makes every other misconfiguration worse. Second, if you drop NET_BIND_SERVICE you cannot bind ports below 1024, which is why containerized services should listen on 8080 and let the port mapping present 80. That is a better arrangement anyway.
Combining these gives a container that cannot write to its own filesystem, cannot escalate, cannot fork-bomb, and holds no capabilities. For a typical stateless HTTP service, nothing breaks. Try it; the failures you find are usually things worth knowing about.
Rootless and user namespaces
Section titled “Rootless and user namespaces”The capability list still starts from “root, minus things”. User namespaces change the starting point.
WITHOUT userns WITH userns
UID 0 in the container UID 0 in the container IS UID 0 on the host, MAPS to UID 100000 on the host, restricted by capabilities which owns nothing and has no and seccomp host privileges at allSo even a complete capability bypass leaves the attacker as an unprivileged host user who owns nothing.
Docker rootless dockerd itself runs as your user. /etc/subuid and /etc/subgid define the mapping. Cost: no ports below 1024 without setcap, slower networking (slirp4netns / pasta), some storage driver constraints, no --net=host in the usual sense.
Podman rootless by default. Same mechanism.
userns-remap classic Docker's middle option: the daemon stays root but containers are remapped. Set "userns-remap": "default" in daemon.json. Note it breaks some volume sharing and --net=host, and existing images/volumes need re-creating.For a developer machine, rootless is a straightforward win. For a production host, it depends on whether you need host networking and privileged ports; increasingly you do not, because something else terminates traffic in front.
Images and the supply chain
Section titled “Images and the supply chain”Pin by digest. Chapter 3’s point, restated as a security control: a tag can be re-pushed. A digest cannot. FROM node:20-slim@sha256:… is the only FROM that means something specific.
Do not put secrets in images. Chapter 3 (whiteouts do not delete), chapter 5 (docker history shows build args), chapter 7 (--mount=type=secret is the answer). To check what is already out there:
docker history --no-trunc myimage every instruction, in full docker save myimage | tar -tv every file in every layerThere are dedicated scanners for this — trufflehog, gitleaks — and running one over your images once is usually informative.
Scan, and then be honest about the output. docker scout cves, trivy image, grype all work well. The difficulty is not detection, it is triage:
a typical debian:12 base image → 40-80 CVEs reported how many are actually exploitable in your service → usually 0-2Most are in packages your application never invokes, or need local access you do not grant, or have no fix available. Blocking every build on “zero CVEs” produces a team that ignores the scanner. What works better:
1. Use a minimal base. distroless or scratch removes most of the surface, and most of the findings, without any triage work. 2. Gate on CRITICAL/HIGH with a fix available. Those are actionable. 3. Rebuild on a schedule with --no-cache and --pull, so patched bases actually reach you (chapter 6: `RUN` never re-evaluates). 4. Track your OWN dependencies separately. A CVE in your framework matters much more than one in the base image's `libtasn6`.Signing and provenance. cosign signs images and docker buildx build --provenance=true --sbom=true attaches an SBOM and build attestation. Verification at deploy time — via a Kubernetes admission policy, per the Kubernetes config and security chapter — is what makes signing worth doing. Signing without verification is a checkbox.
Runtime isolation, when you need more
Section titled “Runtime isolation, when you need more”If you run genuinely untrusted code — a CI service for arbitrary repositories, a code sandbox, a multi-tenant platform — chapter 4’s swappable runtimes are the answer:
gVisor (runsc) a userspace kernel intercepts syscalls. The host kernel sees a small, well-audited surface. Cost: syscall-heavy workloads slow down noticeably.
Kata Containers each container is a real VM with its own kernel. Hardware-level isolation, OCI-compatible. Cost: ~100 ms extra startup, more memory.
Firecracker microVMs, ~125 ms boot, tiny device model. What AWS Lambda and Fargate actually run on.The trade is explicit and you should make it deliberately: shared-kernel containers are not a sufficient boundary for hostile code. They are entirely sufficient for your own services.
Runtime detection
Section titled “Runtime detection”Prevention is most of it, but detection is worth a line. falco watches syscalls via eBPF and alerts on things containers should never do — a shell spawned in a production container, an outbound connection from a process that should not make one, a write to /etc. It catches concern 3 in progress, which prevention alone does not.
A practical checklist
Section titled “A practical checklist” BUILD [ ] pin base images by digest [ ] minimal base (distroless / slim / scratch) [ ] USER non-root in the Dockerfile [ ] no secrets — use --mount=type=secret [ ] .dockerignore excludes .env, .git, credentials [ ] multi-stage: no compilers or package managers in the final image [ ] scan, gate on fixable HIGH/CRITICAL [ ] rebuild on a schedule with --pull --no-cache
RUN [ ] --read-only with --tmpfs where writes are needed [ ] --cap-drop ALL, add back only what is required [ ] --security-opt no-new-privileges [ ] memory, cpu and pids limits set [ ] published ports bound to a specific interface, never 0.0.0.0 [ ] the Docker socket is not mounted anywhere [ ] separate networks; the database is not on the frontend network
HOST [ ] the `docker` group treated as the sudo group it is [ ] rootless or userns-remap where feasible [ ] log rotation configured (a full disk is an outage) [ ] the host kernel is patched — it is the actual boundaryWhat to take from this chapter
Section titled “What to take from this chapter”- A container is a boundary made of kernel features, not hardware virtualization. Fine for your own code; not sufficient for hostile code.
- Most incidents are supply chain and blast radius, not novel kernel escapes. Spend effort there.
- The Docker socket is root on the host. The
dockergroup is the sudo group. Mounting the socket into a CI runner or monitoring agent hands it the machine — use a socket proxy, rootless, or a daemonless build tool instead. --privilegedis not “a bit more”: all capabilities, no seccomp, no AppArmor, writable/sysand/proc, all devices. Escape from there is a known short sequence. Add the one capability or device you need instead.- The five-minute hardening set —
USER,--read-only+--tmpfs,--cap-drop ALL,--security-opt no-new-privileges, and resource limits — breaks nothing for a typical stateless service and removes most of the blast radius. - User namespaces change root-in-container from “root minus capabilities” to “an unprivileged host user that owns nothing”. Rootless Docker and Podman do this by default.
- Pin digests, keep secrets out of layers, and scan — but triage honestly. A minimal base removes more findings than any amount of CVE chasing. Gate on fixable HIGH/CRITICAL and rebuild on a schedule, because
RUNnever re-evaluates on its own. - If you must run untrusted code, gVisor, Kata or Firecracker are the real answer, at a measurable and acceptable cost.
Next: Compose — what it is genuinely excellent at, and the exact point at which it stops being the right tool.