Docker hardening
The sentence that should govern every Docker decision is this: a container shares the host kernel, so a container escape is a kernel escape. A container is not a small VM, it is a process with namespaces and cgroups wrapped around it and a seccomp filter in front of its syscalls and everything below that is the same kernel the host runs. Hardening Docker is therefore mostly about not handing a container the few things that let it reach around the namespaces and talk to that shared kernel directly, and, underneath, about not running the whole apparatus as root in the first place.
The flags that hand over the host
A short list of run-time options turns the isolation off and every one of them shows up in real deployments wearing the excuse “it was the only way to make it work”:
docker run --privileged ... # disables almost all isolation
docker run --cap-add=SYS_ADMIN ... # mount, etc: escape primitive
docker run -v /var/run/docker.sock:/var/run/docker.sock ... # root on the host, laundered
docker run -v /:/host ... # the host filesystem, writable
docker run --pid=host ... # every host process, to inject into
--privileged is the blunt one: inside it, mounting the host disk and chroot-ing into it is a two-line escape, because the container was never really separated from the host’s devices. The Docker socket is the subtle one and the one people mount without thinking, because a process that can talk to the daemon can ask it to start a new privileged container bind-mounting /, which is root on the host through an API call. I have written before
that mounting that socket read-only changes nothing and it bears repeating: the :ro stops you writing the socket file, not the daemon from doing anything you ask it. A bind mount of / is the same escape without the indirection. These are not misconfigurations to tune; they are the isolation being switched off and the hardening is simply to not do them.
The classic demonstration that it is the kernel underneath, not a sandbox, is the cgroup v1 release_agent escape: a privileged container writes a host path into release_agent and that program runs on the host when the cgroup empties. It is patched-around and needs the privilege, but it is the clearest proof that “privileged” means “on the host”.
Run every container as if it will be breached
The defensive default is the inverse of the above: drop everything, add back only what the workload proves it needs and make the container unable to gain privilege even if its process is taken over.
docker run \
--user 1000:1000 \
--cap-drop ALL --cap-add CHOWN \
--security-opt no-new-privileges \
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m \
--pids-limit 200 --memory 512m --cpus 0.5 \
myimage:tag
no-new-privileges is the quiet hero: it stops a setuid binary inside the container from ever raising privilege, which neuters a large class of in-container escalations. cap-drop ALL then cap-add the two or three you actually use is the capability discipline that matters, because the default capability set is far wider than any one service needs and SYS_ADMIN hiding in a broad grant is an escape waiting to be found. Read-only root with a small noexec tmpfs means a compromised process cannot write a payload where it can then execute it. None of this costs the application anything, because a well-built image does not need to write to its own binaries or hold capabilities it never calls.
The image carries half the posture. A minimal base, a non-root USER, no build tools left in the final layer and COPY --chown so the app does not run as root:
FROM alpine:3.20
RUN addgroup -g 1000 app && adduser -D -u 1000 -G app app
WORKDIR /app
COPY --chown=app:app app/ /app/
USER app
EXPOSE 8080
CMD ["/app/run"]
Rootless is the structural fix
Everything above constrains a container; rootless Docker
changes what a successful escape is worth. With the daemon running as an unprivileged user and a user namespace mapping container-root to an ordinary host UID, an escape lands as that unprivileged user, not as host root. Podman
reaches the same place without a daemon at all, which is why I run it for anything new. The cost is real but small: ports below 1024 need a helper, some storage drivers need fuse-overlayfs and --privileged genuinely cannot do everything it does rootful, which is mostly a feature. The prerequisites are user namespaces enabled and subuid/subgid ranges allocated:
echo "$USER:100000:65536" | sudo tee -a /etc/subuid /etc/subgid
curl -fsSL https://get.docker.com/rootless | sh
A container escape that yields an unprivileged user instead of root is the difference between an incident and a catastrophe and it is the single highest-leverage change on this page.
Images: scan and verify what you run
A hardened runtime running a backdoored image is still backdoored, so the image supply chain is part of this. Scan before deploy and fail the build on fixable highs and verify provenance rather than trusting a tag:
trivy image --severity HIGH,CRITICAL --exit-code 1 myimage:tag
cosign verify --key cosign.pub myregistry/myimage:tag
Signing with cosign and verifying at admission is the modern path and it is worth remembering why: a tag is a mutable pointer and trusting one is trusting whoever can move it. Pin by digest for the bases you do not build and sign the ones you do.
Monitoring that is worth running
Runtime detection has one genuinely good tool here, Falco, which watches syscalls and fires on the things that matter, a shell spawned in a container, a mount of a sensitive host path, a write under /proc or /sys:
- rule: Privileged container started
condition: container and proc.name=docker and proc.cmdline contains "--privileged"
output: "privileged container (cmd=%proc.cmdline image=%container.image.repository)"
priority: CRITICAL
What I would not build is the thing these guides always include: a shell loop grepping the Docker log and geoiplookup-ing connections to flag “high-risk countries”. It is noise, it profiles by nationality rather than behaviour and it stops nothing. docker-bench-security for a configuration baseline and Falco for runtime signals are the real tools; the rest is theatre.
Where to land
Never --privileged, never the Docker socket in a container, never a bind mount of /, because those are the isolation turned off, not tuned. Run every container with cap-drop ALL, no-new-privileges, a non-root user, read-only root and resource limits, from a minimal image that already runs as non-root. Move to rootless or Podman so that an escape costs an attacker an unprivileged UID rather than the host. Scan and sign images and verify them at admission. The whole thing rests on one fact worth repeating a last time: there is one kernel, the container is on it and everything here is about keeping the container from noticing.
[edit: 2026] The Ansible implementation of all this: the docker and podman roles.