Skip to content
Latchkey LogoLatchkey home

Docker x509 certificate signed by unknown authority in CI

Docker x509 certificate signed by unknown authority in GitHub Actions means the daemon reached your registry, read the certificate it presented, and found no trusted root behind it, so to fix registry TLS you install that CA where Docker looks for it. Marking the registry insecure also silences the error, and it turns off the check that was protecting every layer the job pulls.

Diagram of the three trust stores a CI job uses and which client reads each one
Three places hold a CA, and they serve different clients. Installing one and testing with another is why this error survives the first fix.

What this error means

Public images pull normally and one host fails, every time, at the first request rather than part way through a download. Two wordings are in circulation: the older one ends the Get line with "x509: certificate signed by unknown authority", and newer clients put "tls: failed to verify certificate" in front of it, which is the shape cnoe-io/idpbuilder#520 reports. The failing URL is usually the registry /v2/ endpoint, or something under it, because verification happens during the handshake and the first request is the one that trips it. We did not reproduce this on a runner: it needs a registry holding a certificate from a private CA, which we are not going to stand up on the public internet, so the log below is the shape a private registry prints rather than a recorded run.

The shape a private registry prints (illustrative, not a recorded run)
Error response from daemon: Get "https://registry.internal.example.com:5000/v2/": tls: failed to verify certificate: x509: certificate signed by unknown authority

Three trust stores, three clients

Docker assumes every registry is secure unless told otherwise, and the daemon has its own per-registry certificate directory: a secure registry uses TLS and the daemon reads a CA certificate from certs.d, under a directory named for the registry, as in myregistry:5000/ca.crt. The port is part of that name, so a registry on 5000 and the same host on 443 are different entries.

That directory covers the daemon and nothing else. BuildKit reads its own configuration, and every tool that reaches a registry without going through the daemon, from curl to a Helm client, uses the system trust store. Installing the CA in one store and testing with another is the usual reason a fix appears not to work.

Where the CA goesWhat starts trusting the registry
/etc/docker/certs.d/<host>:<port>/ca.crtThe Docker daemon, for pulls and pushes to that one host
/usr/local/share/ca-certificates/ then update-ca-certificatesThe system store: curl, Helm, language runtimes, most CLIs
ca under [registry."host"] in buildkitd.tomlA buildx builder started with that configuration file
Nothing, plus an insecure registry entryEverything, by skipping verification rather than establishing trust

Common causes

The registry certificate comes from a private CA

The ordinary case. An internal registry, a corporate proxy that re-signs TLS, or a self-signed certificate on a test registry all present a chain that ends in a root the runner has never seen. Nothing is wrong with the certificate; the runner simply has no reason to trust it.

The CA was installed in the wrong trust store

The wasted fix on this page. A certs.d entry does nothing for a build running inside BuildKit or for a curl in the next step, and the system store does not cover a daemon lookup that has its own directory. In our experience the second attempt at this error is the same certificate in a different place.

The directory name does not match the registry reference

The daemon looks up the directory by the host and port exactly as they appear in the image reference. A registry pulled as registry.internal:5000 needs the directory with the port; the same registry behind a proxy on 443 needs the directory without one. A mismatch leaves the file on disk and the daemon unaware of it.

The chain is incomplete rather than untrusted

A server configured to send only its leaf certificate leaves the client unable to reach the root it does trust, and the error reads the same way. Check what the server actually presents before assuming the CA is missing locally, because the fix is on the registry rather than on the runner.

How to fix it

Install the CA where the daemon looks

  1. Create the certs.d directory for that registry, with the host and port spelled as your references spell them.
  2. Write the CA certificate into ca.crt in that directory.
  3. Pull one image from that registry in the same job to confirm the trust took effect.
Terminal
sudo mkdir -p /etc/docker/certs.d/registry.internal.example.com:5000
sudo cp internal-ca.crt /etc/docker/certs.d/registry.internal.example.com:5000/ca.crt
docker pull registry.internal.example.com:5000/acme/api:1.4.2

Add it to the system store for everything else

Anything that is not the daemon reads the system bundle: curl, Helm, a language HTTP client, a scanner. Copy the CA into the system directory and refresh the bundle, in the same step that wrote the daemon copy, so the two can never drift apart.

Terminal
sudo cp internal-ca.crt /usr/local/share/ca-certificates/internal-ca.crt
sudo update-ca-certificates
curl -sSf https://registry.internal.example.com:5000/v2/ --output v2.json && echo trusted

Give BuildKit its own copy

A buildx builder does not read the daemon certificate directory. Point the builder at a configuration file that names the CA for that registry, and create the builder with it. A build that pulls a base image from the private registry needs this even when docker pull already works.

buildkitd.toml
# buildkitd.toml
[registry."registry.internal.example.com:5000"]
  ca = ["/etc/docker/certs.d/registry.internal.example.com:5000/ca.crt"]

Check what the server sends before blaming the runner

If the chain is incomplete, no local certificate fixes it for every client. Print the chain the registry presents and count the certificates: a leaf with no intermediate is a server configuration problem, and fixing it there removes the error for every job and every tool at once.

Terminal
echo | openssl s_client -connect registry.internal.example.com:5000 -showcerts \
  | grep -c "BEGIN CERTIFICATE"

Why this starts on a hosted runner and not on your laptop

A machine that already trusts your internal CA never shows you this error, and a workstation joined to a corporate network usually does trust it. A GitHub-hosted or managed runner is a fresh machine every job: it has the public CA bundle its image shipped with and nothing of yours. The same workflow that passed for years on a self-hosted runner with the CA baked into the image fails on its first hosted run.

That is also the shape of the fix. There is no setting inside the workflow that makes an untrusted certificate trusted; something in the job has to place the CA on the machine before the first pull, or the runner image has to carry it.

.github/workflows/ci.yml
- name: Trust the internal registry CA
  run: |
    D=/etc/docker/certs.d/registry.internal.example.com:5000
    printf "%s\n" "${{ secrets.INTERNAL_CA_PEM }}" > internal-ca.crt
    sudo mkdir -p "$D"
    sudo cp internal-ca.crt "$D/ca.crt"
    sudo cp internal-ca.crt /usr/local/share/ca-certificates/internal-ca.crt
    sudo update-ca-certificates

The insecure flag is not the fix, and it is not equivalent

Docker documents the trade honestly, in the dockerd page we read on 2026-09-20: enabling an insecure registry allows unencrypted or untrusted communication, which "can be useful when running a local registry", and "because its use creates security vulnerabilities it should only be enabled for testing purposes". It removes the error by removing the check, so a job that pulls a tampered layer has nothing left to notice it.

It is also more work than the fix in a CI job. An insecure registry entry means writing daemon.json and restarting the daemon, which on a hosted runner is a step you have to add anyway. Writing one ca.crt file instead is fewer lines, and it keeps the verification you paid for when somebody set up TLS on that registry.

/etc/docker/daemon.json
# what to avoid on anything that touches real images
{ "insecure-registries": ["registry.internal.example.com:5000"] }

What the runner does about it

Latchkey has no repair for this failure and claims none. A missing root certificate is a statement about the machine's trust configuration, and an automatic retry would either fail identically or, if it did something, would have to weaken verification to succeed. That is not a repair anyone should ship.

The durable version of the fix belongs to the runner image rather than to the workflow. On a fleet you control, bake the CA into the image once, so no job needs a step for it and no secret needs to carry a certificate that is not secret in the first place.

How to prevent it

  • Bake the internal CA into the runner image on any fleet you control.
  • Keep the daemon copy and the system copy in one step, so they cannot drift.
  • Name the certs.d directory from the reference, port included.
  • Never leave an insecure registry entry behind after a debugging session.

Frequently asked questions

Why does Docker trust a public registry and not ours?
Because the runner image ships the public CA bundle and not your root. Docker assumes every registry is secure and verifies the chain it is given, so a certificate from an internal CA fails verification on a machine that has never been told about that CA. Install the root, and the same pull works.
Can I just add the registry to insecure-registries?
You can, and Docker's own documentation says it allows unencrypted or untrusted communication and should only be enabled for testing purposes. In CI it is also not less work: both routes need a file written on the runner, and one of them keeps verification. Use the CA.
I installed the CA and docker pull works, but the build still fails. Why?
Because the build is BuildKit, and BuildKit reads its own registry configuration rather than the daemon certificate directory. Give the builder a buildkitd.toml naming the CA for that host and recreate the builder with it. The same reasoning applies to Helm, curl and any scanner in the job: they read the system store.
Does this error mean the certificate has expired?
No. An expired certificate reports expiry, and a hostname mismatch reports the name it expected. This error is specifically about the chain: the client could not build a path from the certificate it received to a root it trusts, which is either a missing CA locally or an incomplete chain from the server.

Related guides

References

Internal registry, private CA, same workflow. Latchkey runs it at $0.0025/min at 2 vCPU. Start free → 30-day trial · No credit card