Skip to main content
RunBook Academy

Docker & ContainersXXV Β· Certificates & PKIDaemon PKI

Protecting the daemon socket with mutual TLS

Advanced⏱ ~24 min

What you'll learn

  • Explain why an exposed daemon socket is equivalent to root on the host
  • Configure dockerd and a client for mutual TLS on port 2376
  • State what this design cannot do, and choose the SSH transport when it is the better answer

Prerequisites

Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-11

Not yet marked complete on this device.

The Docker documentation puts it without softening: anyone with the keys β€œcan give any instructions to your Docker daemon, giving them root access to the machine hosting the daemon.”

That is not a warning about a weak spot. It is a description of the API’s intended power. A client that can call the daemon can start a container with the host root filesystem bind-mounted and no namespaces, and there is no permission model inside Docker that prevents it. Access to the daemon is root.

Which makes the decision to expose it over the network a serious one, and makes the difference between --tls and --tlsverify the most consequential two characters in this course.

Do not expose it at all, if you can avoid it

Before building a PKI, check whether you need one. The SSH transport gives you a remote daemon connection over an authentication system you already run, already rotate, and already audit:

Configuration changessh transport
docker context create prod-1 \
--docker host=ssh://deploy@docker-1.example.com

docker context use prod-1
docker version

The daemon keeps listening only on its unix socket. Authorisation is whatever your SSH configuration says, revocation is removing a key from authorized_keys, and the audit trail is the one you already collect. For a human operating a handful of hosts this is the right answer almost every time.

Mutual TLS earns its complexity when the client is a machine β€” a CI runner, a deployment controller, a monitoring agent β€” that should not hold an SSH key with shell access.

The PKI

Use a CA created for this purpose and nothing else. Do not reuse the corporate CA: every certificate that CA has ever signed would otherwise become a valid client certificate for your daemon.

  1. One CA, scoped to the daemon fleet. Offline private key, long validity, distributed as ca.pem to daemons and clients alike.
  2. A server certificate per daemon host. The subject alternative names must include every name and IP a client will use to reach it. A client connecting by IP against a certificate with only a DNS SAN fails verification.
  3. A client certificate per client identity, with extendedKeyUsage = clientAuth. One per CI runner, per operator, per controller β€” never a shared certificate, because a shared certificate cannot be revoked without revoking everyone.
  4. Short validity on client certificates. Ninety days is a reasonable ceiling; the reason is in the revocation section below.

Configuring the daemon

The documented invocation:

Configuration changedockerd
dockerd \
--tlsverify \
--tlscacert=/etc/docker/tls/ca.pem \
--tlscert=/etc/docker/tls/server-cert.pem \
--tlskey=/etc/docker/tls/server-key.pem \
-H unix:///var/run/docker.sock \
-H tcp://0.0.0.0:2376

Port 2376 is the convention for the TLS socket; 2375 is the convention for the plaintext one, and 2375 should never be listening on anything but a loopback address.

In daemon.json:

Configuration change/etc/docker/daemon.json
{
"tlsverify": true,
"tlscacert": "/etc/docker/tls/ca.pem",
"tlscert": "/etc/docker/tls/server-cert.pem",
"tlskey": "/etc/docker/tls/server-key.pem",
"hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2376"]
}

Configuring the client

The client looks for three specific filenames in DOCKER_CERT_PATH: ca.pem, cert.pem and key.pem. Rename them and the client reports missing certificates rather than wrong ones.

Read-only / Safeclient
export DOCKER_HOST=tcp://docker-1.example.com:2376
export DOCKER_TLS_VERIFY=1
export DOCKER_CERT_PATH="$HOME/.docker/docker-1"

ls "$DOCKER_CERT_PATH"
docker version --format '{{.Server.Version}}'

A context is the better shape, because it names the whole bundle and survives shell sessions:

Configuration changedocker context
docker context create docker-1 \
--docker "host=tcp://docker-1.example.com:2376,ca=$HOME/.docker/docker-1/ca.pem,cert=$HOME/.docker/docker-1/cert.pem,key=$HOME/.docker/docker-1/key.pem"

docker context ls
docker --context docker-1 info --format '{{.Name}} {{.ServerVersion}}'

Verify the handshake independently when it does not work. openssl tells you which side objected:

Read-only / Safehandshake
openssl s_client -connect docker-1.example.com:2376 \
-CAfile "$HOME/.docker/docker-1/ca.pem" \
-cert "$HOME/.docker/docker-1/cert.pem" \
-key "$HOME/.docker/docker-1/key.pem" < /dev/null

β€”tls is not β€”tlsverify

Daemon flagsEncryptedClient authenticatedEffect
none, -H tcp://...nonoanyone who can route to the port is root on the host
--tlsyesnoanyone who can route to the port is root on the host, privately
--tlsverifyyesyesonly holders of a certificate signed by your CA

The middle row is the dangerous one, because it produces a green padlock in every tool and passes a casual review. Encryption without authentication protects the contents of the instruction to take over the host. It does not stop the instruction.

Sanity check

Knowledge check Β· 4 questions

  1. Q1. A daemon is started with --tls, --tlscert and --tlskey, listening on tcp://0.0.0.0:2376. What has this achieved?

  2. Q2. A CI runner client certificate is leaked. It has ten months of validity left. What actually revokes it?

  3. Q3. Which of these are sound practices when exposing the daemon over mutual TLS? Select all that apply.

  4. Q4. Using DOCKER_HOST=ssh://user@host avoids opening any additional listening port on the Docker host.

Passing score: 75%. Answers are checked in this browser.