Your docker.sock:ro is not read-only
The :ro flag on a Docker socket bind-mount stops you writing to one file. It does nothing to the API behind it. Here is why, and what to do instead.
If you give a container access to the Docker socket, you have probably written this:
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
The :ro reads like a safety belt. A monitoring agent, a dashboard, a backup tool — it only needs to look at Docker, so you mount the socket read-only and move on.
It is not a safety belt. The :ro flag makes one file read-only. It does nothing to the Docker API on the other end of that file. A container with a read-only socket mount can still start a second container that mounts your host’s root filesystem and runs as root. At that point the :ro has protected nothing.
What :ro actually does
A Unix socket is a file. The :ro flag applies to that file’s inode, the same as it would for any other bind-mount. So this is true:
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro alpine \
touch /var/run/docker.sock
touch: /var/run/docker.sock: Read-only file system
You cannot write to the socket file. You cannot rename it, truncate it, or change its permissions. That is the entire extent of what :ro buys you.
But you do not talk to the Docker daemon by writing to the socket file. You talk to it by opening the socket and sending HTTP requests through it. Opening a socket and writing a request body into the connection is not a write to the inode — it is exactly what the socket is for. The :ro flag has no opinion about the bytes you send once the connection is open.
The part that should worry you
The Docker daemon runs as root and will do what any client asks, because a client of the socket is the daemon’s administrator. There is no lesser privilege level to drop to. So a container with the socket mounted — read-only or not — can send this:
POST /containers/create
{
"Image": "alpine",
"HostConfig": {
"Binds": ["/:/host"],
"Privileged": true
}
}
That request creates a new container. The new container bind-mounts the host’s entire root filesystem at /host and runs privileged. From inside it, an attacker reads and writes every file on the host as root: your SSH keys, your other containers’ volumes, /etc/shadow, the lot. The :ro on the original mount did not enter into it, because no file on the original mount was ever written.
You can watch the API call go straight through the read-only mount with nothing more than the Docker CLI:
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro docker:cli \
docker run --rm -v /:/host alpine cat /host/etc/hostname
<your host's hostname>
The outer container mounts the socket read-only. It then asks the daemon to start an inner container that mounts the host root. The daemon obliges. One read-only socket, full host access.
Why this surprises people
Three habits line up to hide it:
:romeans read-only everywhere else. On a config file or a data directory, the flag does what it says. The socket is the one case where the file and the thing behind the file are different, and the flag only reaches the file.- “The container only reads.” True of your code. Not true of a leaked credential or a dependency with a remote-code-execution bug. The threat model is not “my tool behaves” — it is “my tool, or anything that gets code execution inside it, can reach the socket.”
- The tool asked for
:ro, so it must be safe. The tool asked for:robecause that is the only knob the socket mount offers. It is not a statement about what the tool, once compromised, is able to do.
None of this is a Docker bug. The socket is an administrative interface by design. The mistake is reading :ro as “scoped access” when it means “one read-only file in front of an unscoped API.”
What to do instead
Do not hand the raw socket to a container that only needs to read. Put a filtering proxy in front of the daemon and give the container the proxy, not the socket.
wollomatic/socket-proxy is a small proxy for this job. It holds the only bind-mount of the real socket. It forwards a request only when the method and the path match an allowlist, and the allowlist is one regular expression for each HTTP method. A method without a pattern is refused, so nothing passes by default. Write down every request your tool makes, and allow only those:
services:
docker-proxy:
image: wollomatic/socket-proxy:1.13.1
user: "0:0"
environment:
SP_LISTENIP: 0.0.0.0
SP_ALLOWFROM: your-tool
SP_ALLOW_HEAD: /_ping
SP_ALLOW_GET: '/v1\.[0-9]+/containers/(json|[0-9a-f]{64}/(json|stats|logs))'
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
your-tool:
image: your/tool
environment:
DOCKER_HOST: tcp://docker-proxy:2375
This allowlist fits a tool that lists the containers and reads their stats and logs. It permits five requests: the ping, and the container list, inspect, stats, and logs. Each line has a reason:
SP_ALLOW_HEAD: /_ping. The official Docker Go client sendsHEAD /_pingto negotiate the API version. A client in another language can use a different request.SP_LISTENIPandSP_ALLOWFROM. By default the proxy listens on127.0.0.1and accepts requests only from127.0.0.1/32. Without these two lines, no other container can use it. With them, the proxy accepts your tool and answers every other container on the network with403 Forbidden.user: "0:0". The image runs as user 65534. On Linux the Docker socket belongs toroot:dockerwith mode0660, so that user cannot open it. The README of the proxy keeps user 65534 and adds the ID of thedockergroup, and that ID is different on each host. A process that can open the socket controls the daemon in both cases, so the container gets no capabilities and a read-only filesystem.[0-9a-f]{64}. This segment accepts only a full container ID, which is the form that the container list returns. A tool that addresses a container by its name needs a different pattern.- Every
|is inside parentheses. The proxy adds^and$around each pattern as plain text, soa|ballows every path that starts withaor ends withb.
The proxy logs each refused request as blocked request, with the method and the path. To find the requests of your own tool, start it behind the proxy and read that log.
Your tool reads DOCKER_HOST and talks to the proxy over the internal network. The official Docker clients honour DOCKER_HOST with no code change, so the tool never knows it is talking to a proxy. The POST /containers/create attack from earlier now returns 405 Method Not Allowed, because the proxy has no pattern for POST.
The filter by path matters. A proxy that only turns whole sections of the API on and off forwards every read request under /containers when the container section is on. That includes export, which streams the whole filesystem of a container. The pattern above refuses export, archive, and attach with 403 Forbidden.
This is defence in depth, not a cure. The pattern allows inspect, and inspect returns the environment variables of a container — and so every secret you pass in one. The proxy cannot remove a field from a response. So you still run a tool you trust, drop its capabilities, and make its own filesystem read-only. But the proxy closes the one hole that :ro on the socket leaves wide open: the jump from “can read Docker” to “is root on the host.”
How Lumin handles it
Lumin monitors Docker Compose stacks with one agent on your host. The agent reads container stats and tails container logs, which means it needs the Docker API — exactly the position this whole article is about.
So the agent never mounts the socket. A wollomatic/socket-proxy sidecar with the allowlist above holds the bind-mount and forwards only the five requests the agent makes: the ping, and the container list, inspect, stats, and logs. Every other path gets a 403, every other method gets a 405, and every container except the agent gets a 403. The agent reaches the proxy through DOCKER_HOST, and that variable is the whole of its Docker access. A compromised agent cannot create a container, exec into one, or read its files, so the escape from the start of this article is closed. It can still inspect containers, and inspect returns environment variables, so treat the environment variables of your containers as readable by it. That is the only setup Lumin documents, and the agent security model explains the rest of the threat model.
If you run a small stack and want uptime checks, logs, metrics, and Slack alerts without putting an SDK in your services or a raw socket in a container, Lumin is live in about ten minutes.