Docker & ContainersXXV Β· Certificates & PKIRegistry trust
Registry TLS β certs.d, private CAs, and the insecure escape hatch
What you'll learn
- Place a private registry CA where the Docker daemon will read it
- Map each x509 error message to the configuration that causes it
- State precisely what insecure-registries turns off, and what it costs
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
$ docker pull registry.example.com/platform/api:1.4.2Error response from daemon: Get "https://registry.example.com/v2/": tls:
failed to verify certificate: x509: certificate signed by unknown authorityIllustrative output
$ curl -sS -I https://registry.example.com/v2/HTTP/1.1 401 Unauthorized
docker-distribution-api-version: registry/2.0
www-authenticate: Bearer realm="https://registry.example.com/token"Illustrative output
curl is happy. The host trusts the registryβs CA. docker pull
still refuses, and the reason is the first thing to internalise about
registry TLS: the client you typed is not the client that
connects.
The daemon does the pulling
docker is a thin API client. It sends βpull this referenceβ to the
daemon over the socket, and the daemon opens the TLS connection to
the registry. So the trust that matters is the daemonβs, evaluated in
whatever environment the daemon process runs in.
The daemon does use the hostβs system trust store β a registry
certificate signed by a public CA needs no configuration at all. What
it also has, and what curl does not, is a per-registry override
directory.
/etc/docker/certs.d
The documented layout is one directory per registry:
/etc/docker/certs.d/
βββ registry.example.com:5000
βββ ca.crt Root CA that signed the registry certificate, in PEM
βββ client.cert Client certificate
βββ client.key Client key
Two rules about the directory name, both of which cause silent no-ops when broken:
- The directory name is the registry host exactly as it appears in
the image reference, including the port.
registry.example.com:5000andregistry.example.comare different directories. - If you access the registry on the default port 443 without writing the port in the reference, do not put the port in the directory name either.
Under rootless Docker the daemon cannot read /etc/docker; the
equivalent path lives in the userβs config directory,
~/.config/docker/certs.d/.
- Obtain the CA that signed the registry certificate, in PEM form, from whoever runs the registry. Do not scrape it off the connection you are trying to verify.
- Create the directory named for the registry host and port and place the CA at
ca.crtinside it. The.crtextension is required. - Retry the pull. Nothing else is needed if the trust was the only problem.
- If the pull still fails with the same error, restart the daemon and retry once. A restart interrupts containers without
live-restore, so treat it as a maintenance action rather than a reflex. - Verify from the daemon side rather than from
curl: a successfuldocker pullof any tag from that registry is the only proof that counts.
Client certificates to the registry
client.cert and client.key in the same directory make the daemon
present a client certificate β mutual TLS to the registry. Harbor and
self-hosted Distribution deployments in regulated environments use
this instead of, or alongside, a token.
REG=registry.example.com:5000
sudo install -d -m 0755 "/etc/docker/certs.d/$REG"
sudo install -m 0644 ca.pem "/etc/docker/certs.d/$REG/ca.crt"
sudo install -m 0644 client.pem "/etc/docker/certs.d/$REG/client.cert"
sudo install -m 0400 client-key.pem "/etc/docker/certs.d/$REG/client.key"
ls -l "/etc/docker/certs.d/$REG"The daemon runs as root, so 0400 is sufficient and correct. A
world-readable private key on a build host is a finding in every
audit that has ever looked for it.
Reading the error message
Each of these means something different and has a different fix. The habit worth building is to read the whole message before acting on the first three words.
| Message | Meaning | Fix |
|---|---|---|
x509: certificate signed by unknown authority | the CA is not trusted by the daemon | place ca.crt in certs.d |
x509: certificate is valid for a, b, not registry.example.com | SAN mismatch | reissue the certificate with the right SAN, or use a name that is on it |
x509: certificate has expired or is not yet valid | expiry, or host clock skew | renew, or fix NTP on the host |
http: server gave HTTP response to HTTPS client | the registry speaks plain HTTP | put TLS on the registry; insecure-registries only as a stopgap |
remote error: tls: bad certificate | the registry demanded a client certificate it did not get or did not accept | supply client.cert and client.key |
unauthorized: authentication required | TLS succeeded; this is genuinely auth | docker login |
insecure-registries: what it actually switches off
{
"insecure-registries": ["registry.example.com:5000"]
}For a registry on that list the daemon will accept a certificate it cannot verify, and will fall back to plain HTTP if TLS is not available at all. It is a per-registry setting, it requires a daemon restart, and it must be applied on every host that pulls β which is precisely why it drifts. One node in the fleet without it produces an intermittent deployment failure that looks like a registry outage.
The honest use of the flag is as a time-boxed stopgap while a proper certificate is issued, recorded somewhere that will be read again. Anything longer than that is a decision to run without transport security, and it should be written down as one.
Sanity check
Knowledge check Β· 4 questions
Q1. curl https://registry.example.com/v2/ succeeds from the host shell, but docker pull from the same registry fails with x509: certificate signed by unknown authority. What does this tell you?
Q2. Your registry runs at registry.example.com:5000. Which certs.d directory does the daemon read?
Q3. Which of these are true of the insecure-registries daemon setting? Select all that apply.
Q4. A build using the docker-container buildx driver reads the host /etc/docker/certs.d for private registry CAs.
Passing score: 75%. Answers are checked in this browser.