Daemon & socket hardening
Protect docker.sock; lock down daemon.json.
Shodan, the search engine that indexes machines exposed to the internet, currently lists tens of thousands of Docker daemons answering on TCP port 2375 with nothing in front of them. No password. No certificate. Each one is a root shell for whoever sends the right HTTP (HyperText Transfer Protocol) request. That is the uncomfortable fact behind daemon hardening. The Docker API (application programming interface, the set of requests the daemon will accept) has no built-in authentication, the daemon itself runs as root, and every request it accepts runs with the daemon's privilege. Reach the API and you are root on the host. The API listens in two places: the Unix socket at /var/run/docker.sock, which exists on every Docker host, and a TCP port if somebody switches one on. People remember to firewall the port. They forget the socket.
# The 'dev' user is unprivileged. Their only power is membership in the docker group.$ iduid=1000(dev) gid=1000(dev) groups=1000(dev),998(docker)$ docker run -v /:/host -it alpine chroot /host shsh-5.2# id # inside the chroot we are root on the HOST filesystemuid=0(root) gid=0(root) groups=0(root)sh-5.2# head -1 /etc/shadowroot:$6$Xy9k...:19710:0:99999:7:::sh-5.2# echo 'dev ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers # host-root persistence
The socket behaves like a keyhole in a door that has no lock. Docker never asks who you are. It checks only whether your hand fits through. Three things make it fit: sitting in the docker group (the socket file is owned by that group and writable by it), having the socket bind-mounted into a container with -v /var/run/docker.sock, or an open TCP listener. So treat docker-group membership as a passwordless root account rather than a developer convenience, and hand it out with the care you would give the root password. If you genuinely have to reach the daemon across a network, expose only the TLS (Transport Layer Security, the encryption behind https) port 2376 with mutual TLS (mTLS, where the client and the server each present a certificate proving who they are), and firewall it down to named hosts. Better still, do not expose it at all. Reach it over SSH (Secure Shell) or a Docker remote context instead.
Find every hand already on the socket
You cannot harden what you cannot see, so start by mapping who can reach the API right now. Two questions cover most of it. Which running containers have been handed the socket? And which users sit in the docker group? Each answer is one command, and both belong in whatever regular audit you already run.
# Which running containers have the socket bind-mounted in?$ docker ps -q | xargs docker inspect \--format '{{.Name}}{{range .Mounts}} {{.Source}}{{end}}' | grep docker.sock/ci-runner /var/run/docker.sock # a CI runner holding full host root: investigate# Who is effectively root through the group?$ getent group dockerdocker:x:998:dev,jenkins# Log every open of the socket at the kernel level$ sudo auditctl -w /var/run/docker.sock -p rwxa -k docker-sock$ sudo ausearch -k docker-sock -i | tail -1type=PROCTITLE proctitle=curl --unix-socket /var/run/docker.sock http://./containers/json
Set safe defaults in daemon.json
/etc/docker/daemon.json is the daemon's own config file, the house rules that every container inherits. Get it right and a rushed docker run with no security flags still lands somewhere sane. Three switches carry most of the weight. no-new-privileges: true is a ratchet that only turns one way. It sets the kernel's no_new_privs bit on every container, so once a process has started it can never end up with more privilege than it began with. Setuid binaries (programs that run as their owner rather than as the person who launched them) stop paying out, and su and sudo die as escalation paths. userns-remap: default is witness protection for the container's root account. It turns on user-namespace remapping (userns, the feature that hands each container its own private range of user ID numbers) and maps container UID 0 (user ID zero, root inside the container) onto an unprivileged host UID such as 100000. Inside the box you look like root. To the host you are user 100000, a stranger whose badge opens nothing. live-restore: true keeps containers running while the daemon restarts. That sounds like an uptime feature. It is really a security one, because it removes the last excuse anybody has for not restarting dockerd to apply a patch. Add icc: false to stop containers on the default bridge network chatting to each other, and cap the log files so one noisy container cannot fill the disk.
{"no-new-privileges": true,"userns-remap": "default","live-restore": true,"icc": false,"log-driver": "json-file","log-opts": { "max-size": "10m", "max-file": "3" }}
Restart the daemon, then prove the settings actually took hold. A config file is a claim. The running daemon is the evidence.
$ sudo systemctl restart docker # live-restore keeps running containers up$ docker info --format '{{.SecurityOptions}}'[name=apparmor name=seccomp,profile=builtin name=cgroupns name=userns]$ docker run --rm alpine grep NoNewPrivs /proc/self/statusNoNewPrivs: 1 # set for every container; docker info won't show it$ docker run --rm alpine cat /proc/self/uid_map0 100000 65536 # container root (0) maps to host UID 100000$ docker inspect --format '{{.State.Running}} since {{.State.StartedAt}}' webtrue since 2026-07-16T08:31:07Z # survived the restart: live-restore held it up
Settings drift. Someone loosens one during a 3am incident and never puts it back. docker-bench-security runs the CIS (Center for Internet Security) Docker Benchmark, a published checklist of host and daemon settings, against your machine and flags anything that has slipped.
$ docker run --rm --net host --pid host --userns host --cap-add audit_control \-v /var/run/docker.sock:/var/run/docker.sock:ro -v /etc:/etc:ro \docker/docker-bench-security[INFO] 2 - Docker daemon configuration[PASS] 2.2 - Ensure network traffic is restricted between containers on the default bridge[PASS] 2.9 - Enable user namespace support[WARN] 2.12 - Ensure that authorization for Docker client commands is enabled[PASS] 2.15 - Ensure live restore is enabled
Some containers really do need the API. A CI (continuous integration, the system that builds and tests your code on every commit) runner builds images. Traefik reads container labels so it knows where to route traffic. Watchtower polls for newer image versions. Give each of them a filtered view instead of the raw socket. A proxy such as tecnativa/docker-socket-proxy sits in front of the daemon and forwards only the endpoints you allow. Let Traefik read GET /containers and nothing else, and a compromised Traefik can list your containers but cannot create one that mounts the host. A receptionist who takes messages is a very different thing from a key to the whole building.
Anyone who can talk to the Docker API can usually become root on the host: mount /, chroot into it, done. Membership in the docker group is the same power under a friendlier name. An unauthenticated TCP 2375 is a public root shell, and scanners find those within hours of them appearing.
Harden daemon.json with the same instinct. Turn off experimental features you do not need, be fussy about which authorization plugins you load, prefer a TLS-authenticated TCP port if a remote API is genuinely mandatory, and keep the file permissions on the Unix socket tight.
CI runners that hand docker.sock to build jobs inherit this entire problem, and build jobs run code you did not write. A carefully configured Docker-in-Docker (DinD, a build container that runs its own daemon), rootless mode, or a remote builder behind strong authentication all beat mounting the raw socket into an untrusted build.
In production this becomes a habit rather than a one-off. After every change window, rerun the same checks you ran when you first switched the controls on, paste the command and its output into the ticket, and refuse to close the change if the reading moved. A control nobody re-reads is a control nobody has.
Pick the tightest scope that still lets the workload do its job. One extra endpoint allowed on a socket proxy. One extra name in the docker group. One host where somebody opened the port for an afternoon of debugging and never closed it. Each looks small on its own, and they compound across every host and every pipeline until the tightest thing you have is the audit report.
Prevention slips, so leave a tripwire behind it. The auditd rule above (auditd is the Linux kernel's audit subsystem, which records who touched what) turns every open of the socket into a log line with the process name attached. Docker's own client shows up there constantly, and that is fine. What you alert on is the surprise: curl, python, a shell script, or any process name that has no business holding the daemon's phone.
Keep a written list of everything that legitimately talks to the daemon, with an owner's name and a reason next to each entry. Socket access tends to arrive as a favour for one urgent build and then live forever, because nobody remembers why it was granted or who would notice if it went away. A list with owners on it turns that guesswork into a two-minute review.
Turn userns-remap on in staging before you turn it on anywhere that matters. Remapping shifts the user IDs the host sees, so bind-mounted files can suddenly look owned by the wrong account, storage drivers that do not understand the mapping misbehave, and containers that share the host's process or network namespaces stop working under it. All of that is fixable. None of it is fun to meet for the first time at go-live.
When a colleague asks for the docker group, they are usually asking for something smaller: the ability to build an image, or restart their own service, without pestering you. Say yes to that. Rootless Docker gives them a daemon of their own with no host root behind it. A narrow sudo rule, a self-service pipeline, or a filtered socket proxy each cover the common cases too. Giving someone the whole daemon because the request was phrased that way is how these lists grow.
If you ever find a daemon that was listening on plain TCP, or a socket mounted into a container running an image you cannot vouch for, treat the host as compromised rather than merely misconfigured. Exposed daemons get found by automated crypto-mining crews first, and their usual souvenirs are a new line in /etc/sudoers, an extra SSH key, and a cron job. Rebuild the host, rotate every credential that lived on it, and only then close the ticket.
None of this stops at Docker. Kubernetes nodes run containerd or CRI-O, and those daemons have sockets of their own with exactly the same property: whoever can send them requests can start a container with whatever privilege they like. A pod that mounts the node's runtime socket owns the node. The name on the socket changes. The reasoning does not.
Try this
Prove the point to yourself on a lab machine you are happy to destroy. Log in as an unprivileged account that belongs to the docker group, start a container that mounts the host's root filesystem, and write a file that account has no business writing. Then look at the permissions on the socket and work out, in your own words, why the group is root-equivalent. Lab only.
$ iduid=1000(dev) gid=1000(dev) groups=1000(dev),998(docker)$ docker run --rm -v /:/host alpine sh -c 'echo pwned-from-container > /host/tmp/docker-sock-proof; cat /host/tmp/docker-sock-proof'pwned-from-container$ ls -l /var/run/docker.socksrw-rw---- 1 root docker ... /var/run/docker.sock$ # cleanup on lab: rm /tmp/docker-sock-proof
Takeaway
Treat docker.sock and the docker group as root accounts, because that is what they are. Keep the API off plain TCP, keep the socket out of containers you do not trust, and keep daemon.json down to the features your platform actually uses.