Skip to main content
RunBook Academy

Docker & ContainersXXV Β· Certificates & PKIRegistry trust

Registry TLS β€” certs.d, private CAs, and the insecure escape hatch

Intermediate⏱ ~20 min

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

Not yet marked complete on this device.
Read-only / Safehost shell
$ docker pull registry.example.com/platform/api:1.4.2
Error response from daemon: Get "https://registry.example.com/v2/": tls:
failed to verify certificate: x509: certificate signed by unknown authority

Illustrative output

Read-only / Safesame host, same second
$ 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:5000 and registry.example.com are 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/.

  1. 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.
  2. Create the directory named for the registry host and port and place the CA at ca.crt inside it. The .crt extension is required.
  3. Retry the pull. Nothing else is needed if the trust was the only problem.
  4. 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.
  5. Verify from the daemon side rather than from curl: a successful docker pull of 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.

Configuration changepermissions
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.

MessageMeaningFix
x509: certificate signed by unknown authoritythe CA is not trusted by the daemonplace ca.crt in certs.d
x509: certificate is valid for a, b, not registry.example.comSAN mismatchreissue the certificate with the right SAN, or use a name that is on it
x509: certificate has expired or is not yet validexpiry, or host clock skewrenew, or fix NTP on the host
http: server gave HTTP response to HTTPS clientthe registry speaks plain HTTPput TLS on the registry; insecure-registries only as a stopgap
remote error: tls: bad certificatethe registry demanded a client certificate it did not get or did not acceptsupply client.cert and client.key
unauthorized: authentication requiredTLS succeeded; this is genuinely authdocker login

insecure-registries: what it actually switches off

Configuration change/etc/docker/daemon.json
{
"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

  1. 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?

  2. Q2. Your registry runs at registry.example.com:5000. Which certs.d directory does the daemon read?

  3. Q3. Which of these are true of the insecure-registries daemon setting? Select all that apply.

  4. 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.