"It's just inside a container, so it's isolated" is the assumption that quietly turns a compromised app into a compromised server. A default container is isolation, not a sandbox - and two lines that appear in an enormous number of self-hosting compose files, the Docker socket mount and the privileged flag, hand a compromise the whole host. This piece is about where the container boundary actually is, and the common configurations that erase it.
//A Container Shares the Kernel
A virtual machine runs its own kernel on virtual hardware; escaping it means breaking the hypervisor. A container does not. Every container on a host shares that host's single Linux kernel, and the isolation is a set of kernel features - namespaces, cgroups, capabilities, seccomp and LSM profiles - layered on top of normal processes. NIST's container security guidance (SP 800-190) states it plainly: the shared kernel means a larger attack surface than a hypervisor, and the isolation a container runtime provides is not as strong as a VM's. A kernel bug, or a configuration that removes the layers, and a "contained" process is on the host.
//The Docker Socket Is Root, Full Stop
The single most common way people erase the boundary is mounting the Docker socket into a container: - /var/run/docker.sock:/var/run/docker.sock. Tools like watchtower, Portainer and Traefik ask for it, because it is how they talk to the Docker daemon. The problem is that the Docker daemon runs as root, and anything that can reach its socket can drive the entire Docker API. OWASP's Docker Security Cheat Sheet puts it without hedging: giving something access to the socket is equivalent to giving unrestricted root access to your host.
There is no clever exploit involved. A compromised container with the socket simply asks the daemon to start a new container - one that bind-mounts the host's entire filesystem at /, or runs privileged - and now reads and writes any file on the host, including the ones that grant it a root shell. It is not a kernel escape; it is the attacker using the root-level API you handed them exactly as designed.
:ro feels safer and is not: a read-only socket mount does not make the Docker API read-only. You can still create privileged, host-mounting containers through it. If a container genuinely needs the API, put a socket-proxy in front that whitelists only the specific calls it needs - do not hand it the raw socket, read-only or otherwise.//--privileged Turns the Layers Off
The other erasure is the --privileged flag. It is often added to make some hardware-adjacent thing work, and it does far more than its users expect: it grants the container all Linux capabilities, gives it access to every host device, and drops the seccomp and AppArmor confinement that normally stands between a container process and the kernel. That combination is enough to escape to the host by well-documented techniques. Privileged is almost never actually required; the specific capability or device the workload needs can usually be added individually with --cap-add or --device, which is a far smaller hole.
Membership has privileges, too
One level up from the container: being in the docker group on the host is not a convenience, it is a privilege grant. Docker's own documentation states that the docker group confers root-level privileges, for the same reason as the socket - group membership lets you talk to the root daemon. Adding your user to docker to skip typing sudo is, in the normal rootful setup, handing that user root.
//Where the Boundary Actually Holds, and How to Harden It
None of this means containers are insecure - it means a default, unmodified container contains an app-level compromise to that app, and specific configurations break that. To keep and strengthen the boundary: do not mount the socket (use a proxy if you must); never use --privileged when a capability will do; drop all capabilities and add back only what is needed; run a read-only root filesystem; leave the default seccomp and AppArmor profiles on; and run rootless Docker with user-namespace remapping so that root in the container is not root on the host. If you need genuine sandbox-grade isolation - running untrusted code - reach for gVisor, which interposes a userspace kernel, or Kata, which wraps the container in a lightweight VM. Both are stronger than a plain container; neither is quite a full VM, and each has trade-offs.
docker.sock or set privileged: true? If so, that service is not contained - a compromise of it is a compromise of the host, and you are trusting the image and everything it pulls accordingly. Everything else about the container's hardening is secondary to those two lines. This connects to the broader self-hosting lesson in who can read your traffic: know what each convenience actually costs you.//Sourcing and Framing
The socket-equals-root statement and the read-only-socket caveat are from the OWASP Docker Security Cheat Sheet; the host bind-mount mechanism is Docker's own daemon attack-surface documentation. The effects of --privileged (all capabilities, all devices, confinement disabled) are from OWASP and Trail of Bits' container-escape analysis, with the caveat that the specific cgroup release_agent escape is one cgroup-v1 technique rather than the only path. The docker-group "root-level privileges" wording is Docker's post-install documentation. The shared-kernel, isolation-not-a-sandbox framing is NIST SP 800-190. The gVisor and Kata descriptions are from their own project documentation; the article is careful not to equate either with a full VM. The interpretation is that two specific, extremely common config lines are responsible for most of the gap between "it's in a container" and reality.