tutorials8 min read
Docker Socket Hardening: Stop Mounting Root-Equivalent Into Every Container
Your containers stop being a free elevator to the host — and you can prove it with one deliberate test. Audit the raw /var/run/docker.sock mounts, front them with Tecnativa's socket proxy (pinned v0.5.0), then userns-remap or rootless for structural isolation.
Anyone who can talk to /var/run/docker.sock owns your host — so audit which containers actually have it (most do), replace raw mounts with Tecnativa/docker-socket-proxy allow-listed per app via env vars, move apps off the API where possible, then consider user namespace remapping or rootless mode for structural isolation. Verified against Docker Engine docs + the proxy README as of 2026-09-18.
Your containers stop being a free elevator to the host — and you can prove it with one deliberate test. If you run Portainer, Watchtower, or any "Docker-aware" app today, chances are good that at least one of them has /var/run/docker.sock mounted raw. That socket is root: whoever can talk to it can mount a host directory as a volume, create a privileged container, and own the box — no CVE required. This is a procedure with a before/after you can screenshot.
Before you start//
- A Docker host (rootful or rootless), ~2–4 hours including testing; comfort with
docker composeand a text editor. - Skill floor: intermediate — you know what a container is and have mounted
/var/run/docker.sockat least once. That's most of this zine's readers, which is exactly why the raw mount is everywhere. - Blast radius: Steps 1–3 touch containers, not the daemon — low risk, rollback is one compose file change +
docker compose up -d. Step 4 (userns/rootless) changes file-ownership assumptions host-wide and needs a migration pass, not a flip — do it on a window where you can babysit. - Verified against: Docker Engine security docs (user namespace remapping, rootless mode) and the Tecnativa/docker-socket-proxy README (repo active — latest commit Jul 27, 2026; pinned below to release
v0.5.0, the newest git release at time of writing), all fetched 2026-09-18.
Step 1: Audit what's actually holding the socket (30 min, do this first)//
Find every container mounting /var/run/docker.sock:
for c in $(docker ps -q); do
echo "== $c"
docker inspect "$c" | grep -o '"Source": *"[^"]*docker\.sock[^"]*"' || true
done
Then make a two-column list: needs it (Portainer, Watchtower, Uptime Kuma-style managers that query the API) vs. probably doesn't (your media stack, sidecars, "it was in the compose file so I kept it"). The second column is bigger than you think — most raw mounts are copy-paste inheritance from a tutorial's example block, not a design decision.
Why: you can't allow-list what you haven't inventoried, and this list is your attack-surface map: each row is an app that currently owns the host if it gets compromised.
Step 2: Front the socket with Tecnativa's proxy (allow-list per app)//
The proxy is a small HAProxy container in front of /var/run/docker.sock that returns HTTP 403 Forbidden for any API section you haven't explicitly granted. The model is env vars, one per API area: 1 grants, 0 revokes. By default only EVENTS, PING, and VERSION are open; the security-critical sections (AUTH, SECRETS, and POST — which when set to 0 makes everything read-only) are revoked, and everything else (CONTAINERS, IMAGES, NETWORKS, VOLUMES, EXEC, …) is opt-in.
Pin a release tag (don't ship :latest in a security control — per the README it tracks the last released git version; :edge is master). Compose shape for a manager that only needs to see containers/images/networks:
services:
docker-proxy:
image: tecnativa/docker-socket-proxy:v0.5.0
privileged: true # required per README — it talks the host socket, and SELinux/AppArmor contexts lock otherwise
environment:
- CONTAINERS=1
- IMAGES=1
- NETWORKS=1
volumes:
- /var/run/docker.sock:/var/run/docker.sock
networks: [dockernet]
# your manager app, pointed at the proxy instead of the raw socket:
mymanager:
image: some/manager:tag
environment:
DOCKER_HOST: tcp://docker-proxy:2375
networks: [dockernet]
networks:
dockernet: {} # private network holding ONLY the proxy and its consumers — per the README's security recommendationsThen in each app's compose block, swap - /var/run/docker.sock:/var/run/docker.sock for DOCKER_HOST=tcp://docker-proxy:2375 (or unix:///path/to/proxy-socket if you wire it that way). Non-compose setup is the same idea — run the proxy with -p 127.0.0.1:2375:2375, then export DOCKER_HOST=tcp://localhost:2375 for whatever consumes it, per the README's usage section.
Start minimal and add on 403. This is where people over-grant out of impatience: if your manager starts throwing errors, read which API call 403'd (the proxy logs them — LOG_LEVEL=debug for detail) and grant just that variable (EXEC=1, VOLUMES=1, POST=1 as needed). A Portainer-class UI legitimately needs more than a Watchtower-class updater; the point is that each grant is now a decision with a log line, not an inheritance.
Why: raw socket = "all or nothing." The proxy turns it into "exactly this much," and the README's own security recommendations are explicit: keep the proxy off any public network, on a Docker network containing only itself and its consumers, and revoke everything the app doesn't need. That last one is your new default posture.
Step 3: Move apps off the API entirely where they can live without it//
Some "Docker-aware" apps only look like they need the socket — status dashboards that could poll an export instead of querying live, health checkers that just need a TCP port. For those: drop DOCKER_HOST/the mount and re-architect to whatever cheaper interface exists. If several managers genuinely need API access, put them on a dedicated manager network or VLAN (the same segmentation instinct as this week's honeypot VLAN build) so the proxy is reachable from three hosts instead of thirty.
Why: every app that keeps a live API connection is a standing privilege. The cheapest permission is the one you don't grant — including "reach the socket at all."
Step 4 (structural): user namespace remapping or rootless mode//
Steps 2–3 fix who can reach the daemon; this step fixes what "root" even means inside it. Both paths are documented in the Docker Engine security docs, and both change file-ownership assumptions — treat them as migrations, not toggles:
User namespace remapping (daemon stays rootful; container "root" maps to an unprivileged host UID):
# 1. Ensure subordinate ranges exist for the user that will own namespaced storage:
grep ^$(whoami): /etc/subuid /etc/subgid # e.g. testuser:231072:65536
# 2. Enable via daemon.json (recommended over the CLI flag per the docs):
{ "userns-remap": "default" } # creates + uses a 'dockremap' user automatically
# or name an existing user instead of "default"
# 3. Restart Docker — and expect some ownership follow-up on bind mounts (see When it breaks)
Per the docs, userns-remap masks existing image/container layers under a subdirectory of /var/lib/docker/ — so it's cleanest on a fresh install or a host you're prepared to migrate. Known limitations worth reading before you flip: no --pid=host/--network=host, external volume drivers must be mapping-aware, and --privileged containers need --userns=host.
Rootless mode (daemon and containers run as your user; the stronger isolation):
sudo apt-get install uidmap # provides newuidmap/newgidmap (prereq per docs)
dockerd-rootless-setuptool.sh install # run AS the non-root user; creates a systemd --user service + CLI context
# then, in your shell:
export DOCKER_HOST=unix:///run/user/1000/docker.sock # path is per-user — yours will differ
sudo loginctl enable-linger <youruser> # daemon survives logout
The rootless doc requires ≥65,536 subordinate UIDs/GIDs in /etc/subuid+/etc/subgid, and notes the usual operational deltas: ports below 1024 need a sysctl tweak, and cgroup v2 is expected. If your system-wide daemon is still running, the doc recommends disabling it first so you're not quietly rootful on both sides.
Why: with either mode, an app escaping its container lands as an unprivileged host UID instead of root — the difference between "elevator to the penthouse" and "stairwell dead end." Rootless is the further step (the daemon itself stops being root); userns-remap gets you most of the way with less migration pain.
Step 5: Plant one tripwire on the manager network//
Per CISA's cyber-decoys guidance (Sep 16, 2026 — the same playbook as today's honeypot post, Step 4): put one decoy on dockernet that nothing managed should ever touch — a fake registry container with a honeytoken credential, or simply alerting on proxy LOG_LEVEL=debug lines showing API calls from a source you didn't whitelist. If an unmanaged app reaches the Docker API at all, that's your page-worthy event: it means someone mounted the socket without asking.
Verify it worked (non-negotiable — the before/after test)//
From a container that should not have Docker access (a media box, anything from Step 1's second column):
curl --unix-socket /var/run/docker.sock http://localhost/containers/json
Expected: failure — no socket mounted (no such file), or if it does reach a proxy, 403 Forbidden for the ungranted section. Now repeat from inside your proxy-fronted manager, pointed at the proxy:
curl -s http://docker-proxy:2375/containers/json | head -c 200
Expected: your container list comes back — through the allow-list, not around it. Screenshot both outputs; that's your before/after and the proof this wasn't just a config shuffle. If you did Step 4, add docker info on the host (rootless shows rootless under Security Options) to prove the daemon moved too.
When it breaks//
- Apps lose file access after userns-remap — bind mounts now belong to the remapped user; apps expecting root-owned files get permission errors. Fix: pre-arrange ownership on those paths, or use SELinux labeling (
:z/:Z) where applicable — the docs call this out as the expected follow-up, and it's why Step 4 is a migration window, not a lunch-break task. - Rootless won't bind :80/:443 — ports below 1024 need a sysctl or RootlessKit port-driver adjustment (per the rootless doc); and if cgroup v2 isn't active, the setuptool will tell you. Rollback:
sudo systemctl enable --now docker.service docker.socketto bring back the rootful daemon. - A manager "works" but quietly can't — the classic silent breakage when the proxy is configured tighter than the app needs (Watchtower stops seeing images; a UI shows an empty list). You'll find it in the app's log, not your dashboard: look for 403s or "no images found," then grant that one API section and re-verify.
Rollback: every step is one compose-file edit + docker compose up -d (Steps 2–5), or restoring daemon.json/disabling the rootless service (Step 4). That reversibility is the whole point of doing it in this order — surface-level first, structural last.