Why does `docker pull` from a private registry fail with `x509: certificate signed by unknown authority`, and where does the CA go?
answer
- Ask which process opens the TLS connection
- Trust is configured on the daemon's host
- A per-registry directory under /etc/docker
- certs.d named host:port, file ca.crt
basics
~20 sThe Docker daemon, not the CLI, verifies the registry's server certificate, and nothing in that chain is trusted on the daemon's host. Install the signing CA at /etc/docker/certs.d/<registry-host>:<port>/ca.crt, or in the host's system trust bundle.
solid answer
~40 s`x509: certificate signed by unknown authority` is a TLS verification failure raised by **dockerd**, which is the process that opens the connection to the registry - your shell, your browser and the `docker` CLI's own trust stores are irrelevant. The daemon verifies against the host's system CA bundle plus a per-registry directory, `/etc/docker/certs.d/<host>[:<port>]/`, where any `*.crt` file is added as an extra CA for that registry and a `client.cert` / `client.key` pair is presented for mutual TLS. The directory name must match the registry part of the image reference exactly, port included - `registry.internal.example.com:8443`. Drop `ca.crt` there and retry; restart the daemon if it still refuses. Adding the host to `insecure-registries` is the wrong fix: that switches verification off and allows a plain-HTTP fallback instead of establishing trust.
code
bash · 3 linessudo mkdir -p /etc/docker/certs.d/registry.internal.example.com:8443
sudo cp corp-root-ca.crt /etc/docker/certs.d/registry.internal.example.com:8443/ca.crt
docker pull registry.internal.example.com:8443/search/doc-indexer:1.4.2go deeper
Be ready to name the error and give the one-line fix without hesitating: the CA file goes on the machine running the Docker daemon, under /etc/docker/certs.d, in a directory named exactly like the registry including its port.
Explain the mechanics: dockerd and not the docker CLI performs the handshake, which files in certs.d mean CA versus client certificate, and why insecure-registries removes verification rather than adding trust.
Demonstrate diagnosis. Read the exact x509 string to separate an unknown CA from a name mismatch or an expired certificate, inspect the served chain with openssl, and recognise a registry that omits its intermediate instead of patching every client.
Own the distribution problem. CAs belong in the base machine image or configuration management rather than a runbook, the same trust must reach remote engines, containerised builders and the containers themselves, and a CA rotation needs a rollout plan before it needs a ticket.
## What the error actually says `x509: certificate signed by unknown authority` comes from the Go TLS stack inside the Docker Engine. When the engine opens `https://registry.internal.example.com:8443/v2/`, the registry presents a certificate chain. The engine walks that chain looking for an issuer it already trusts. If the chain terminates in a certificate authority the engine has never been told about - a corporate internal PKI root, an intermediate issued by one, or a self-signed certificate that is its own issuer - verification fails and the pull is aborted before any authentication, manifest fetch or layer transfer happens. This is a *trust* failure, not a network failure and not an authentication failure. The registry was reachable; the engine simply refused to talk to it. ## Which process is doing the verifying The `docker` CLI is a thin client over the Engine API. When you type `docker pull`, the CLI sends "pull this reference" over `/var/run/docker.sock`; the daemon then conducts the whole registry conversation itself. Three consequences follow, and interviewers ask about all of them: * A CA installed in your browser keychain, your shell's `SSL_CERT_FILE`, or your language runtime does nothing for `docker pull`. * If `DOCKER_HOST` or a `docker context` points at a remote engine, the CA has to exist on **that** host, not on your workstation. * On Docker Desktop the engine runs inside a Linux VM, so a file written to `/etc/docker/certs.d` on macOS is not on the daemon's filesystem at all; Desktop's supported route is to trust the CA in the host operating system's store so it reaches the VM. ## The two places the daemon finds trust **1. The host's system CA bundle.** On Debian and Ubuntu that means copying the certificate into `/usr/local/share/ca-certificates/` and running `update-ca-certificates`; on the RHEL family, `/etc/pki/ca-trust/source/anchors/` and `update-ca-trust`. The daemon builds on the system pool, so this makes the CA trusted for every registry the host talks to - and for everything else on the host. Restart the daemon after changing it. **2. The per-registry directory `/etc/docker/certs.d/<host>[:<port>]/`.** Every file ending in `.crt` in that directory is added as a certificate authority for that one registry. A `client.cert` plus `client.key` pair in the same directory is a *client* certificate the daemon presents when the registry requires mutual TLS. The naming rules bite people constantly: * the directory name must equal the registry portion of the image reference **exactly**, including the port when the reference carries one, so `registry.internal.example.com:8443` needs a directory literally named `registry.internal.example.com:8443`; * a pull of `registry.internal.example.com/...` (implicit 443) will not read the `:8443` directory; * the extension matters - `.crt` means CA, `.cert` means client certificate. The daemon consults that directory when it contacts the registry, so a new file normally takes effect on the next pull; if the pull still fails, restart the daemon before assuming the file is wrong. ## Worked example The search team publishes a Scala document-indexing service to an internal registry on port 8443. The pull fails on a freshly built host: docker pull registry.internal.example.com:8443/search/doc-indexer:1.4.2 Error response from daemon: Get "https://registry.internal.example.com:8443/v2/": tls: failed to verify certificate: x509: certificate signed by unknown authority The fix is two commands and no daemon configuration at all: create `/etc/docker/certs.d/registry.internal.example.com:8443/`, copy the corporate root in as `ca.crt`, pull again. ## Why `insecure-registries` is the wrong answer Adding the host to the `insecure-registries` list in `daemon.json` does not install trust - it removes the requirement for it. The daemon then attempts TLS without verifying the certificate and falls back to plain HTTP if TLS is unavailable, which means anything that can answer on that address can serve you an image. Content digests do not rescue you here, because the manifest that names those digests arrives over the same unverified channel. It is also daemon-wide configuration that must be replicated to every host and requires a daemon restart, so it is not even cheaper than installing the CA. ## Read the exact error before you fix it Three x509 failures look alike at a glance and have completely different fixes: * `signed by unknown authority` - trust problem, install the CA. * `certificate is valid for X, not Y` - the name you pulled is not in the certificate's subject alternative names. No amount of CA installation fixes this; reissue the certificate or use a name it covers. * `certificate has expired or is not yet valid` - real expiry, or a host clock that has drifted. Check the clock before blaming the PKI. A fourth case masquerades as the first: the registry serves only its leaf certificate and omits the intermediate, so no client can build a path. Trusting the intermediate on every host is a workaround; fixing the server's chain is the repair. `openssl s_client -connect host:port -showcerts` shows exactly what is served. ## Where the same trust has to be repeated The daemon is not the only client on the path. A `buildx` builder created with the `docker-container` driver runs in its own container with its own root store and cannot see the host's `certs.d`. Containers you run have their own bundle inside the image. Every other engine in the fleet needs the same file. That is why the CA belongs in the machine image or in configuration management, not in a runbook step someone repeats by hand.
- The CA is installed but the pull now fails with `certificate is valid for registry.internal, not registry.internal.example.com`. What changed?Nothing about trust - that is a name mismatch. The certificate's subject alternative names do not include the hostname you pulled, and adding CAs cannot fix it. Either reissue the certificate with the right SAN, or pull using a name the certificate already covers. Treating it as a missing-CA problem sends people round in circles installing files that were never the issue.
- Your build runs on a buildx builder created with the docker-container driver and still cannot pull, although the host's certs.d has the CA. Why?That builder is a container with its own filesystem and its own root certificate store; it never sees the host's `/etc/docker/certs.d`. You have to supply the CA to the builder itself - through its buildkitd configuration or by mounting the certificate into that container when you create the builder - or build with the default docker driver, which uses the daemon's trust.
- What is the difference between ca.crt, client.cert and client.key in that certs.d directory?`ca.crt` - or any `*.crt` file - is an extra certificate authority the daemon adds when verifying the registry's server certificate. `client.cert` together with `client.key` is a client certificate the daemon presents when the registry demands mutual TLS. The extensions are the whole distinction, so a client certificate saved as `.crt` is silently loaded as a CA and the mutual-TLS handshake still fails.
Handing your laptop's browser a certificate does nothing for the daemon: the daemon is a different program, often on a different machine, keeping its own list of signers it will believe.
saying these in an interview costs you the question
- Reaches for insecure-registries instead of installing the CA
- Thinks the docker CLI performs the TLS handshake
- Installs the CA only on the workstation, not the daemon host
- Expects a docker pull --insecure flag to exist
- Omits the registry port from the certs.d directory name
- Treats a subject-name mismatch as a missing-CA problem