click the screen · press Enter
← back to blog
Kubernetes Journey · Topic 16

An Exposed Docker Daemon Is Root on the Host

Securing the Docker daemon explained - Kubernetes Journey

Back in the 4Cs post I walked an attack chain that started with a single exposed Docker port and ended with a container escape to the host. This is the other half of that story: what that port actually is, why it's so catastrophic when it's open, and how you lock the Docker daemon down properly. Because "an exposed Docker socket is game over" gets repeated a lot, and I wanted to actually understand the why, not just parrot it.

I'll start with the daemon itself, show the door people accidentally leave open, then close it step by step. What, how, why, takeaway.

What the Docker daemon is (and why it's root)

When you type docker run, the docker command isn't doing the work. It's just a client. The real engine is a background program called the Docker daemon (dockerd), and it's the thing that actually pulls images, starts containers, mounts volumes and talks to the kernel. On Linux it usually runs as a systemd service, so it starts at boot and stays up. You can peek at it:

systemctl status docker     # is the daemon running?
systemctl start docker      # start it
systemctl stop docker       # stop it

Here's the part that matters for security: the daemon runs as root, and it can do anything root can. It can start a container that mounts the host's entire filesystem, or one that runs with full privileges. So whoever can send commands to the daemon effectively is root on that machine. Hold that thought, it's the whole point.

Handy for debugging You can also run the daemon in the foreground with dockerd (add --debug for verbose logs printed straight to the terminal). It's useful when something won't start and you want to watch it fail in real time, rather than digging through journald.

How you talk to it: the Unix socket

So how does the docker client reach the daemon? By default, through a Unix socket, a special file at /var/run/docker.sock. A Unix socket is just a local pipe between processes on the same machine, so this is a safe default: only someone already logged into the host (and in the right group) can use it. Nothing on the network can touch it.

So out of the box, Docker is local-only. The danger starts the moment you change that.

The door people leave open: the daemon on TCP

Sometimes you genuinely need to reach the daemon from another machine, managing a server from your laptop, or wiring it into some CI tool. To do that, you tell the daemon to also listen on a TCP port (a network port), not just the local socket. And this is exactly where it goes wrong.

The naive way looks harmless:

dockerd --host=tcp://192.168.1.10:2375

Then from your laptop you point the client at it and it just works:

export DOCKER_HOST="tcp://192.168.1.10:2375"
docker ps

Port 2375 is the plain, unencrypted Docker API. No TLS, no login, nothing. Anyone who can reach that port can run docker commands as root on your host. And people leave these open to the internet constantly, scanners find them in seconds.

what an open 2375 gives an attacker
$ export DOCKER_HOST=tcp://victim:2375 $ docker ps # they can see everything running $ docker run -v /:/host -it alpine chroot /host sh # mounts the whole host filesystem into a container = root on the box # from here: read /etc/shadow, add SSH keys, drop a miner. done.
This is the container escape from the 4Cs post, in two commands. An open daemon isn't a leak, it's root.
Why this is as bad as it gets An exposed daemon lets an attacker delete your containers (downtime), wipe your volumes (data loss), and launch privileged containers that own the host and pivot across your network. This is a favourite for cryptomining crews because it's such easy, reliable root. Treat a naked 2375 as full compromise.

Locking it down, step by step

Right, how to do it properly. The mindset is layered: first don't expose it at all, then if you must, encrypt it, then on top of that, authenticate it. Here's the order I'd work in.

01
Harden the host

SSH keys, no root login, close spare ports

02
Prefer the socket

don't expose TCP unless you truly need to

03
Encrypt (TLS)

if remote, use 2376 + TLS, never 2375

04
Authenticate

tlsverify + client certs, or it's still open

Encryption alone isn't enough. The last step is the one people skip, and it's the important one.

Step 1: secure the host first

Before touching Docker, treat the machine like any server you'd harden. Turn off direct root login, only give accounts to people who need them, use SSH keys instead of passwords, and close ports you're not using. None of the Docker settings below matter if the box itself is soft. This is the same "patch and harden the base" idea from the CIS benchmarks post.

Step 2: if you expose it, bind to a private interface only

If you really do need remote access, at least don't put it where the whole internet can see it. Bind the daemon to a private IP, and double-check it isn't reachable on any public-facing interface. In /etc/docker/daemon.json (this file doesn't exist by default, you create it):

{
  "hosts": ["tcp://192.168.1.10:2375"]
}

That keeps it on the internal network, but it's still unencrypted and unauthenticated. It's a step, not the destination. Keep going.

Step 3: encrypt with TLS on 2376

Now encrypt the connection with TLS (the same certificate tech that puts the padlock on HTTPS). You set up a certificate authority (CA), generate a server certificate and key, switch the port to the TLS convention 2376, and turn TLS on:

{
  "hosts": ["tcp://192.168.1.10:2376"],
  "tls": true,
  "tlscert": "/var/docker/server.pem",
  "tlskey": "/var/docker/serverkey.pem"
}

Now traffic is encrypted, so nobody can sniff your Docker commands off the wire. But, and this catches people, TLS on its own only proves the server's identity and scrambles the traffic. It does nothing to check who the client is. Anyone can still connect; they just do it over an encrypted channel. Encryption is not authentication.

Step 4: authenticate clients with tlsverify

This is the step that actually locks the door. You turn on tlsverify, which tells the daemon: only accept clients whose certificate was signed by our CA. Now the CA has to sign a client certificate too, and only people you hand that certificate to can connect. Both sides prove themselves. The full daemon.json:

{
  "hosts": ["tcp://192.168.1.10:2376"],
  "tls": true,
  "tlsverify": true,
  "tlscacert": "/var/docker/cacert.pem",
  "tlscert": "/var/docker/server.pem",
  "tlskey": "/var/docker/serverkey.pem"
}

On the client side you hand over your signed certificate. Docker even auto-detects certs placed in ~/.docker, or you can point at them by hand:

export DOCKER_TLS_VERIFY=true
export DOCKER_HOST="tcp://192.168.1.10:2376"
docker --tlscacert=ca.pem --tlscert=client.pem --tlskey=client-key.pem ps

tls encrypts and tlsverify authenticates, and you need both. Encryption without authentication is a locked-looking door that anyone can walk through.

Two gotchas from the docs First: after editing daemon.json, restart the service (systemctl restart docker) for it to take effect. Second: don't set the same option in both daemon.json and on the command line, Docker sees the conflict and refuses to start. Pick one place (the file is the tidy choice).

Finding an exposed daemon (yours or theirs)

From the attacker's side this is trivial recon, which makes it a great audit for defenders too. The check is simply: is anything answering on 2375 or 2376, and does it talk without a certificate?

is a daemon exposed? (check your own hosts)
$ curl -s http://192.168.1.10:2375/version {"Version":"...","ApiVersion":"..."} # answered with NO auth = wide open   $ curl -s https://192.168.1.10:2376/version curl: (56) ... certificate required # good: it demands a client cert # if 2375 answers, treat that host as compromised until proven otherwise
Verify step: a plain answer on 2375 is a finding. A certificate demand on 2376 is what you want to see.
Defender checklist Keep the daemon on the Unix socket wherever you can. Never expose 2375. If you need remote access, bind to a private interface, enable TLS on 2376, and enforce tlsverify with CA-signed client certs. Firewall the port to known admin IPs. And remember the socket itself is powerful, mounting /var/run/docker.sock into a container hands that container root on the host, so treat that mount like handing over the keys.

What finally sat right

Two posts in, the exposed-Docker-daemon thing finally sits right in my head. It's not that Docker is insecure, it's that the daemon is root by design, so the only real question is who's allowed to talk to it. Local socket: fine. TCP with no auth: you've handed root to the network. The bit I want to hammer home to myself is that encryption and authentication are different jobs, TLS alone feels secure and isn't, and tlsverify is the flag that actually matters. I've done the daemon.json side but not yet stood up my own CA and minted client certs end to end, so that's the next lab, and I suspect the cert paths will fight me. Next in the series I want to bring this back up to Kubernetes and look at the container runtime there, now that the Docker foundations make sense. If you audit hosts, tell me, do you still find open 2375s in the wild? I bet you do.

If this cleared up the Docker daemon for you, come say hi on LinkedIn or the contact page, and tell me what to break down next. More in the Kubernetes Journey.

Further reading

FAQ

Why is exposing the Docker daemon on TCP dangerous?

The Docker daemon runs as root and can start any container, mount any host path and read any volume. If you expose its API on a TCP port with no encryption or authentication, anyone who reaches that port effectively gets root on the host. It is one of the fastest paths to full compromise.

What is the difference between Docker ports 2375 and 2376?

Port 2375 is the plain, unencrypted Docker API port and should never be used on a real network. Port 2376 is the convention for the TLS-secured API. Using 2376 with tlsverify means traffic is encrypted and only clients holding a valid CA-signed certificate can connect.

How do I secure the Docker daemon?

Keep it on the local Unix socket unless you truly need remote access. If you must expose it, bind only to a private interface, enable TLS on port 2376, and turn on tlsverify with CA-signed server and client certificates so encryption and authentication are both enforced via daemon.json.