Docker & ContainersXXV Β· Certificates & PKIDaemon PKI
Protecting the daemon socket with mutual TLS
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
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:
docker context create prod-1 \
--docker host=ssh://deploy@docker-1.example.com
docker context use prod-1
docker versionThe 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.
- One CA, scoped to the daemon fleet. Offline private key, long validity, distributed as
ca.pemto daemons and clients alike. - 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.
- 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. - 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:
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:2376Port 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:
{
"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.
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:
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:
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 flags | Encrypted | Client authenticated | Effect |
|---|---|---|---|
none, -H tcp://... | no | no | anyone who can route to the port is root on the host |
--tls | yes | no | anyone who can route to the port is root on the host, privately |
--tlsverify | yes | yes | only 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
Q1. A daemon is started with --tls, --tlscert and --tlskey, listening on tcp://0.0.0.0:2376. What has this achieved?
Q2. A CI runner client certificate is leaked. It has ten months of validity left. What actually revokes it?
Q3. Which of these are sound practices when exposing the daemon over mutual TLS? Select all that apply.
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.