Safeguard
Container Security•

Docker Pull Certificate Errors: Fix the Chain, Not the Flag

How to fix 'x509: certificate signed by unknown authority' on docker pull without ignoring certificate verification: per-registry CA in /etc/docker/certs.d, OS trust stores (update-ca-certificates, update-ca-trust), Docker Desktop, BuildKit, containerd hosts.toml, Kubernetes nodes and Podman registries.conf, plus why insecure-registries is a supply chain risk.

Safeguard Security Team
34 min read

You run docker pull registry.example.com/team/api:1.8.0 and get this back:

Error response from daemon: Get "https://registry.example.com/v2/": tls: failed to verify certificate: x509: certificate signed by unknown authority

The fastest answer on most forums is one line in daemon.json: add the registry to insecure-registries, restart Docker, done. It works. It also turns off the only thing that proves the server you are pulling from is the registry you meant, on every host where you paste it, for as long as nobody remembers to take it out. The registry project's own documentation describes that setting as exposing the registry "to trivial man-in-the-middle (MITM) attacks" and says to use it only for isolated testing or a tightly controlled, air-gapped environment.

This guide is about the other fix: teaching each client to trust the right certificate authority, scoped as narrowly as the platform allows. It covers what the error actually means, how to diagnose which of the usual causes you have, and the exact commands for Docker Engine on Linux, Docker Desktop on macOS and Windows, BuildKit, containerd and nerdctl, Kubernetes nodes, and Podman. Then it explains, concretely, why skipping verification is a software supply chain problem and not just a hygiene issue. Every command and file path here comes from the Docker, distribution, containerd, containers/image, Kubernetes and operating system documentation linked in the Sources.

Key takeaways

  • "x509: certificate signed by unknown authority" means the TLS client could not build a chain from the registry's certificate to a CA it trusts. On docker pull, the client doing that check is the Docker daemon (or containerd, or Podman), not your shell. Fix trust where the pull actually happens.
  • Diagnose before you fix. A private CA, a missing intermediate certificate, a corporate TLS inspection proxy, a hostname mismatch and an expired certificate all produce x509 errors, and each has a different correct fix.
  • The narrowest safe fix on Docker Engine is a per-registry CA file: /etc/docker/certs.d/<host>:<port>/ca.crt. The distribution docs note that this does not need a Docker restart. The broader fix is the OS trust store (update-ca-certificates on Debian and Ubuntu, update-ca-trust on RHEL and Fedora), followed by a daemon restart.
  • Docker Desktop reads the host's trust store: the macOS keychain or the Windows Trusted Root Certification Authorities store. Restart Docker Desktop after adding the CA.
  • containerd and Kubernetes nodes use hosts.toml files under a config_path such as /etc/containerd/certs.d, with a ca field per registry host. Podman uses /etc/containers/certs.d/<host>:<port>/ca.crt. imagePullSecrets is authentication, not TLS trust, and does not fix an x509 error.
  • insecure-registries, skip_verify = true, insecure = true and --tls-verify=false all remove server authentication. Anyone on the network path can then answer for your registry, and Docker will also fall back to plain HTTP if HTTPS is not available. Treat any of them as a temporary exception with an owner and an expiry, never as a fix.
  • After trust is fixed, verify what you pulled: pin by digest, verify signatures, and scan the image in CI before it ships.

What the error actually means

When a client connects to a registry over HTTPS, the server presents its leaf certificate and, usually, one or more intermediate certificates. The client tries to build a path from that leaf up to a root CA in its trust store, checking signatures, validity dates, key usage and that the certificate names the host it connected to. Docker, containerd and Podman are written in Go, and Go's TLS stack reports a failure to reach a trusted root as x509: certificate signed by unknown authority.

Three points about that sentence save most of the debugging time.

First, the check happens in the process that makes the HTTPS request. With Docker Engine, docker pull is a CLI call to the daemon over its API socket, and the daemon talks to the registry. Installing a CA for your user, or exporting SSL_CERT_FILE in your shell, does nothing for a daemon running as a system service. On Docker Desktop the daemon runs inside a managed Linux VM, which is why Docker Desktop has its own mechanism for picking up host certificates. On Kubernetes the kubelet asks the container runtime to pull, so the runtime's configuration on each node is what matters.

Second, "unknown authority" does not always mean the CA is unknown. It means no path could be built. If the registry serves only its leaf certificate and omits the intermediate, a client that trusts the root may still fail, because it has no way to connect the leaf to the root. Browsers often hide this problem because they cache intermediates or fetch them; command-line clients are stricter. The fix for that case is on the server.

Third, other x509 messages are related but different, and the insecure flag "fixes" all of them by hiding them:

MessageWhat it meansCorrect fix
x509: certificate signed by unknown authorityNo chain to a trusted rootTrust the issuing CA on the pulling host, or make the server send its intermediates
x509: certificate is valid for a.example.com, not registry.example.comHostname not in the certificate's Subject Alternative NamesReissue the certificate with the right SAN, or pull using a name it covers
x509: certificate relies on legacy Common Name field, use SANs insteadCertificate has a CN but no SAN; Go ignores CN for host checksReissue with a subjectAltName
x509: certificate has expired or is not yet validOutside the validity window, or the host clock is wrongRenew the certificate, or fix NTP on the client
tls: oversized record received / http: server gave HTTP response to HTTPS clientRegistry is speaking plain HTTPPut TLS in front of the registry

Only the first row is about trust configuration on the client. The rest are certificate or server problems, and adding a CA will not help.

Diagnose first: which chain are you actually seeing?

Before editing any configuration, look at the certificate the pulling host receives. Run these on the host that does the pull (the Docker host, the Kubernetes node, the CI runner), not on your laptop, because a proxy or DNS difference can mean the two see different certificates.

# Show the chain the server sends, with SNI set to the registry name
openssl s_client -connect registry.example.com:443 \
  -servername registry.example.com -showcerts </dev/null

# Summarise the leaf: who it is for, who issued it, when it expires
openssl s_client -connect registry.example.com:443 \
  -servername registry.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates -ext subjectAltName

# Ask the registry API root; curl uses the OS trust store
curl -v https://registry.example.com/v2/

Read the output against five common causes.

A private or internal CA. The issuer is something like "Example Corp Issuing CA" and you know your platform team runs a PKI. The registry is fine; your host simply does not trust that CA yet. This is the most common case for self-hosted registries, Harbor, and artifact managers behind an internal certificate. Fix: trust the CA, using the per-registry or OS-level method below.

A missing intermediate. -showcerts prints only one certificate, the leaf, and its issuer is an intermediate rather than a root. openssl s_client will often report verify error:num=20:unable to get local issuer certificate. Fix: configure the registry or the load balancer in front of it to send the full chain (leaf followed by intermediates). You can work around it on clients by trusting the intermediate, but every new client will hit the same wall, so fix the server.

A TLS inspection proxy. The issuer is your corporate proxy or firewall product, not the registry's real CA, and you see the same issuer for unrelated public sites such as registry-1.docker.io. The proxy is terminating TLS and re-signing with its own root. Fix: either install the proxy's root CA in the trust store of the pulling hosts (a decision for your security team, because it means trusting the proxy for everything) or exempt registry hostnames from inspection. Do not respond by marking Docker Hub or your cloud registry as insecure.

A hostname mismatch. The SAN list does not include the name you pulled with, often because you used an IP address, a short name, or a load balancer DNS name. Fix: pull with a name the certificate covers, or reissue it with the right SANs.

Time. notBefore is in the future or notAfter is in the past. On a freshly booted VM or an edge device with no NTP, the client's clock may be wrong rather than the certificate. Fix: renew, or fix the clock.

If curl on the host succeeds but docker pull still fails, the difference is almost always that the OS trust store was updated after the daemon started (restart the daemon), or that the pull is happening somewhere else, such as inside a BuildKit container or a Docker Desktop VM.

Fix 1: trust a CA for one registry on Docker Engine (Linux)

Docker Engine supports per-registry certificate directories. Docker's documentation on verifying a repository client with certificates describes the layout: create a directory under /etc/docker/certs.d named after the registry host, including the port if it is not 443, and put the CA certificate in it with a .crt extension.

# The registry CA (PEM), obtained from your PKI team, not from the TLS handshake
sudo mkdir -p /etc/docker/certs.d/registry.example.com:5000
sudo cp example-corp-ca.pem /etc/docker/certs.d/registry.example.com:5000/ca.crt

docker pull registry.example.com:5000/team/api:1.8.0

For a registry on the default port 443, the directory name is just the host:

/etc/docker/certs.d/
└── registry.example.com/
    └── ca.crt

Details from the Docker documentation that matter in practice:

  • Files ending in .crt are treated as CA roots. Files ending in .cert are client certificates and .key are client keys, used when the registry requires mutual TLS. Note the different extension: client.cert and client.key, not client.crt.
  • On Linux, the roots in that directory are merged with the system's default roots, so trusting a private CA for one registry does not stop the daemon trusting public CAs for others.
  • The distribution project's guide says that with the CA placed at /etc/docker/certs.d/<host>:<port>/ca.crt on every Docker host, "You do not need to restart Docker."
  • The directory name must match what you type in the image reference. If you pull registry.example.com:5000/..., the directory is registry.example.com:5000. If you pull through an alias or an IP, that is a different directory.

Where does the CA file come from? From your PKI team, your registry's administrator, or the certificate bundle in your configuration management. Do not copy it out of the openssl s_client -showcerts output from the same connection that is failing. If that connection is being intercepted, you would be pinning the attacker's certificate into your trust configuration. This is the same trust-on-first-use trap discussed in our post on certificate pinning and whether you should use it: you need an out-of-band, authenticated source for the anchor you are about to trust.

Fix 2: trust the CA in the operating system store

Sometimes the per-registry directory is not enough. The distribution documentation notes that "When using authentication, some versions of Docker also require you to trust the certificate at the OS level," because the token endpoint used for authentication can be a different host from the registry, and other tools on the same machine (curl, cosign, Helm, package managers) read the OS store, not Docker's directory.

The OS store is also the right answer when the CA is an internal root that should be trusted for many services, not one registry.

Debian, Ubuntu and Alpine use update-ca-certificates. Put the PEM file in /usr/local/share/ca-certificates/ with a .crt extension, then rebuild the bundle:

sudo cp example-corp-ca.pem /usr/local/share/ca-certificates/example-corp-ca.crt
sudo update-ca-certificates
sudo systemctl restart docker

RHEL, CentOS Stream, Fedora, Rocky, AlmaLinux and Oracle Linux use the shared system trust managed by update-ca-trust. Put the PEM file in the anchors directory and extract:

sudo cp example-corp-ca.pem /etc/pki/ca-trust/source/anchors/example-corp-ca.pem
sudo update-ca-trust extract
sudo systemctl restart docker

The update-ca-trust man page documents /etc/pki/ca-trust/source/anchors/ as the place for additional trust anchors in PEM or DER format, and extract as the command that regenerates the consolidated bundles that applications read.

Restart the daemon. Unlike the per-registry directory, a change to the OS store is read when the process loads its roots. Restart Docker (or containerd, or the kubelet's runtime) so it picks up the new bundle.

The trade-off: the OS store is host-wide and protocol-wide. Every process on the machine will now accept certificates issued by that CA for any hostname. That is appropriate for a company root that already issues internal certificates. It is not appropriate for a one-off self-signed certificate on a test registry, where the per-registry directory keeps the scope tight.

Fix 3: Docker Desktop on macOS and Windows

Docker Desktop runs the Engine in a VM it manages, so you do not edit /etc/docker/certs.d on the host directly. Instead, Docker Desktop picks up CAs from the host trust store.

macOS. Add the CA to the keychain, then restart Docker Desktop. For all users:

sudo security add-trusted-cert -d -r trustRoot \
  -k /Library/Keychains/System.keychain example-corp-ca.crt

For your user only:

security add-trusted-cert -d -r trustRoot \
  -k ~/Library/Keychains/login.keychain example-corp-ca.crt

Docker's macOS documentation describes adding a custom CA to the keychain this way and notes that Docker Desktop picks it up; restart Docker Desktop for the change to take effect. Client certificates for mutual TLS go in ~/.docker/certs.d/<host>:<port>/client.cert and client.key.

Windows. Docker Desktop for Windows trusts certificates in the Windows certificate store under Trusted Root Certification Authorities (and Intermediate Certification Authorities for intermediates). The distribution guide gives the GUI path: right-click the certificate, choose Install certificate, select Local Machine, place it in Trusted Root Certification Authorities, finish, and restart Docker Desktop. From an elevated PowerShell prompt, the equivalent is:

Import-Certificate -FilePath .\example-corp-ca.crt `
  -CertStoreLocation Cert:\LocalMachine\Root

Then restart Docker Desktop.

Fix 4: BuildKit and docker buildx

A frequent surprise: docker pull works after Fix 1, but docker build fails on FROM registry.example.com/base/python:3.12 with the same x509 error. The reason is that a build using the docker-container driver (common with docker buildx create) runs BuildKit inside its own container, which has its own filesystem and does not see the host's /etc/docker/certs.d.

BuildKit has its own configuration file, buildkitd.toml, with a per-registry section. Docker's BuildKit configuration reference documents ca, insecure and http keys:

# buildkitd.toml
[registry."registry.example.com:5000"]
  ca = ["/etc/certs/example-corp-ca.pem"]

Create a builder that uses it:

docker buildx create --name corp-builder \
  --driver docker-container \
  --config ./buildkitd.toml \
  --use

The CA file referenced in ca is read when the builder is created, so recreate the builder after changing it. Resist insecure = true and http = true here for the same reasons as elsewhere: the base image is the foundation of everything you ship, so the pull of a FROM line is the worst place in the pipeline to stop authenticating the server.

Fix 5: containerd, ctr, nerdctl and crictl

containerd's registry host configuration lives in a directory of hosts.toml files. The containerd documentation describes the layout: a config_path directory (conventionally /etc/containerd/certs.d) containing one subdirectory per registry host namespace, each with a hosts.toml.

/etc/containerd/certs.d/
├── registry.example.com:5000/
│   ├── ca.crt
│   └── hosts.toml
└── docker.io/
    └── hosts.toml

A hosts.toml that trusts a private CA for one registry:

# /etc/containerd/certs.d/registry.example.com:5000/hosts.toml
server = "https://registry.example.com:5000"

[host."https://registry.example.com:5000"]
  capabilities = ["pull", "resolve"]
  ca = "/etc/containerd/certs.d/registry.example.com:5000/ca.crt"

The ca field accepts a single path or a list of paths, and a relative path is resolved against the host directory. The docs also define client for mutual TLS certificates and skip_verify, which "skips verifications of the registry's certificate chain and host name." The default is false; leave it that way.

Wiring it up for the CRI plugin. Kubernetes and crictl pull through containerd's CRI plugin, which needs config_path set in /etc/containerd/config.toml. For containerd 1.x configuration version 2:

[plugins."io.containerd.grpc.v1.cri".registry]
  config_path = "/etc/containerd/certs.d"

For containerd 2.x configuration version 3:

[plugins."io.containerd.cri.v1.images".registry]
  config_path = "/etc/containerd/certs.d"

Restart containerd after changing config.toml. The containerd docs note that updates inside the hosts directory itself do not require restarting the daemon, so once config_path is set, adding or rotating a CA is a file change.

The older registry.configs and registry.mirrors sections in the CRI configuration are deprecated in favour of config_path. If you find insecure_skip_verify = true in an old config.toml, that is the legacy form of the same exception and worth removing during the migration.

ctr. The low-level client does not read config_path from the daemon. Pass the directory explicitly:

sudo ctr images pull --hosts-dir /etc/containerd/certs.d \
  registry.example.com:5000/team/api:1.8.0

nerdctl. nerdctl reads hosts.toml from /etc/containerd/certs.d when running rootful and ~/.config/containerd/certs.d when rootless, and it also recognises Docker's certs.d layout (/etc/docker/certs.d rootful, ~/.config/docker/certs.d rootless). It offers an --insecure-registry flag; the same caution applies.

crictl. crictl pull goes through the CRI plugin, so it uses whatever config_path containerd has. It is a good way to test node configuration without scheduling a pod:

sudo crictl pull registry.example.com:5000/team/api:1.8.0

Fix 6: Kubernetes nodes

On a Kubernetes cluster, the error shows up as a pod stuck in ErrImagePull or ImagePullBackOff, with the x509 message in kubectl describe pod. The Kubernetes documentation on images is explicit that the kubelet asks the container runtime to pull, so the fix is node configuration for the runtime, using Fix 5 on every node that might run the workload.

Two misconceptions cost teams hours here.

imagePullSecrets does not fix TLS. An image pull secret supplies registry credentials. It is authentication of you to the registry. An x509 error is a failure to authenticate the registry to you, and it happens before credentials are ever sent. No amount of secret configuration will make the runtime trust an unknown CA.

Mounting a CA into the pod does not help either. The pull happens on the node before any container exists. A CA bundle in a ConfigMap is useful for the application's own outbound TLS, not for the image pull.

The durable pattern is to make CA trust part of node provisioning:

  • Bake the CA and hosts.toml into the node image, or into the bootstrap script your node groups run, so every new node is correct from the first pull.
  • Put the CA into the OS store as well (Fix 2) when the same nodes need it for other clients, and restart the runtime as part of bootstrap.
  • For existing nodes, roll the change through the same configuration management you use for kernel parameters, then drain and replace nodes rather than editing them in place where possible, so the configuration is reproducible.
  • On managed Kubernetes services, use the provider's supported node customisation mechanism. The specifics differ per provider, so follow their current documentation rather than a blog snippet.

Fix 7: Podman, Buildah and Skopeo

Podman, Buildah and Skopeo share the containers/image library, which reads per-registry TLS configuration from certs.d directories. The containers-certs.d(5) man page defines the layout: a directory named after host:port under /etc/containers/certs.d (system-wide) or $HOME/.config/containers/certs.d (rootless user), containing *.crt CA certificates, and optionally a *.cert and *.key client pair.

sudo mkdir -p /etc/containers/certs.d/registry.example.com:5000
sudo cp example-corp-ca.pem /etc/containers/certs.d/registry.example.com:5000/ca.crt

podman pull registry.example.com:5000/team/api:1.8.0

For rootless Podman, the same file in ~/.config/containers/certs.d/registry.example.com:5000/ca.crt scopes the trust to one user.

The insecure option for these tools lives in registries.conf, documented in containers-registries.conf(5):

# /etc/containers/registries.conf  (do not do this as a fix)
[[registry]]
location = "registry.example.com:5000"
insecure = true

The man page says insecure = true allows unencrypted HTTP as well as TLS connections with untrusted certificates. On the command line, podman pull --tls-verify=false and skopeo copy --src-tls-verify=false do the same thing for one invocation. Skopeo also takes --src-cert-dir and --dest-cert-dir to point at a specific certificate directory, which is a better one-off option because it keeps verification on.

Fix the server, not just the clients

Every client-side fix above multiplies: each new laptop, runner and node needs it. Several root causes are cheaper to fix once on the registry side.

  • Serve the full chain. Configure the registry, reverse proxy or load balancer to send the leaf followed by every intermediate up to (but not including) the root. Then clients that already trust the root need nothing extra.
  • Use the right SANs. Every name clients use to reach the registry belongs in the certificate's Subject Alternative Name extension, including any port-qualified alias you advertise in documentation. Certificates that rely only on the Common Name fail in Go clients.
  • Automate renewal. Expired registry certificates break every pull at once, usually at the worst time, and the emergency response tends to be an insecure flag that outlives the incident. Short-lived certificates with automated renewal remove the temptation.
  • Prefer an issuing CA your fleet already trusts. If your organisation has an internal root distributed to all managed hosts, issue the registry certificate from it, so the trust problem is solved by provisioning you already run.

Why insecure-registries is a supply chain risk

It is easy to treat insecure-registries as a harmless convenience for an internal network. Look at what it actually does.

The distribution documentation describes Docker's behaviour for a registry in that list: first try HTTPS; if HTTPS is available but the certificate is invalid, ignore the certificate error; if HTTPS is not available, fall back to HTTP. In other words, the daemon no longer requires proof that the server is the registry, and it will accept a plaintext connection if one is offered. The same documentation warns that this exposes the registry to "trivial man-in-the-middle (MITM) attacks." The dockerd reference also accepts CIDR ranges in insecure-registries, so a single entry such as a whole private address block can switch off verification for every registry in it. And Docker treats registries on loopback addresses (127.0.0.0/8) as insecure by default.

Now walk through what an attacker on the network path can do once verification is off.

Serve a different image under the same tag. Tags are mutable pointers. When you pull team/api:1.8.0, the client asks the registry which manifest that tag points to and trusts the answer. Without server authentication, whoever answers the TLS (or HTTP) connection can return any manifest and any layers they like, consistent with each other. The client will check that the blobs match the digests in the manifest it received, but it received that manifest from the impostor. The result is a perfectly valid image that you never published, tagged with a name you trust, now running in your environment.

Steal registry credentials. Docker's login flow sends credentials to the registry or its token service. If verification is off, those credentials go to whoever answered. With the HTTP fallback, they may cross the network in a form that any observer on the path can read. Registry credentials often allow push as well as pull, which turns a one-time interception into a persistent way to place images in your real registry.

Poison the base of every build. The most consequential pulls are not docker run on a laptop. They are FROM lines in CI and pulls on production nodes. If a build host has the registry marked insecure, every image built there inherits whatever base layer the network path delivered. The compromise then travels through your own signing and promotion steps, which faithfully attest to a build that started from untrusted input.

Make the exception permanent and invisible. daemon.json entries are host configuration. They get copied into golden images, Ansible roles, Terraform user data, onboarding scripts and CI runner templates, and they rarely appear in code review. Months later nobody knows which hosts skip verification for which registries.

NIST SP 800-190, the Application Container Security Guide, names "insecure connections to registries" as a registry risk and recommends that organisations configure their development tools, orchestrators and container runtimes to connect to registries only over encrypted channels. "Encrypted" there is meant in the full TLS sense, with server authentication. A connection that encrypts traffic to an unauthenticated peer protects you from passive eavesdroppers and from nobody else.

"But we pull by digest"

Pinning by digest (image@sha256:...) is a real control and you should do it. When you pull by digest, the client verifies that the manifest it receives hashes to the digest you asked for, so an impostor cannot substitute different content for that exact reference. That helps integrity. It does not make insecure registries safe:

  • The digest has to come from somewhere trustworthy. If it was looked up through the same unauthenticated connection (for example by resolving a tag in a script), it proves nothing.
  • Credentials still go to whoever answered.
  • Most real-world references, including many FROM lines and Helm values, still use tags.
  • An attacker who cannot substitute content can still deny service or observe what you pull.

Digest pinning and verified TLS solve different problems. You want both.

If you truly need an exception

There are legitimate short-term cases: a disposable lab registry on a laptop, an air-gapped test environment, a bootstrap sequence where the CA is being installed. If you must skip verification, keep it as small and as visible as possible.

  • Scope to one exact host and port. Never a wildcard, a CIDR block or a public registry name.
  • Prefer per-command flags to host configuration. podman pull --tls-verify=false or skopeo copy --src-tls-verify=false affect one invocation. A daemon.json entry affects every pull on that host until someone removes it.
  • Keep it off CI runners and production nodes. These are exactly the places where an untrusted base image does the most damage.
  • Give it an owner and an expiry. Record why the exception exists, who owns it and when it will be removed, in the same place you track other risk acceptances.
  • Detect it. Add a check to your configuration management or host baseline that flags insecure-registries in daemon.json, skip_verify = true in containerd hosts.toml, insecure_skip_verify in legacy CRI configuration, and insecure = true in registries.conf.

After trust is fixed: verify what you pulled

Fixing the trust chain tells you that you are talking to your registry. It does not tell you that what the registry holds is safe to run. Two more checks belong in the same pipeline.

Verify signatures and attestations. If your images are signed, verify the signature against a key you obtained out of band before you deploy. Safeguard's documentation on attestation and signing describes how artifacts and attestations Safeguard produces (SLSA provenance, CycloneDX and SPDX SBOMs, vulnerability scan snapshots) are signed with Safeguard's own ECDSA P-256 key, held in a cloud key management service, and verified with cosign against the published public key:

KEY=https://registry.safeguard.sh/_gold/keys/cosign.pub

# Container image signature
cosign verify --key "$KEY" --insecure-ignore-tlog \
  registry.safeguard.sh/library/python@sha256:<digest>

# SLSA provenance attestation for the same image
cosign verify-attestation --key "$KEY" --insecure-ignore-tlog \
  --type slsaprovenance registry.safeguard.sh/library/python@sha256:<digest>

The flag name deserves a sentence, because this post is about not ignoring things. --insecure-ignore-tlog has nothing to do with TLS certificate verification. It tells cosign not to look for a public transparency log entry, because, as the Safeguard docs state, Safeguard signs with a key and does not write signatures to a public transparency log. The signature itself is still verified against the published key, and cosign still uses normal TLS verification when it talks to the registry and fetches the key. The docs are also clear about the trade-off: a public transparency log gives an independent public record of every signature; with key-based signing you rely on Safeguard to protect the key.

Note that cosign reads the OS trust store, not Docker's certs.d directory. If your registry uses a private CA, Fix 2 is what lets cosign reach it with verification on.

Scan the image you are about to ship. Safeguard's container image guide adds one step after the pipeline builds or pulls an image. It scans the image on the runner, records it in Safeguard under the image repository with one version per environment, and can fail the build on a severity threshold:

safeguard ci scan --image "registry.example.com/team/api:$TAG" \
  --environment prod --wait --fail-on critical

When the step runs on the machine that built the image, the scan reads it from the local Docker daemon with nothing extra to set up. Otherwise it pulls from the registry using SAFEGUARD_REGISTRY_USERNAME and SAFEGUARD_REGISTRY_PASSWORD set as CI secrets, and the docs note that only the results leave the runner. In the Safeguard app, each container registry card under Integrations has a Set up CI/CD button that generates the steps for your CI, image and environments. Per-environment thresholds let you, for example, fail on critical findings in production and only report in development.

Put together: the pulling host trusts the registry's CA with verification on, deployments reference images by digest, signatures are verified against keys obtained out of band, and the image is scanned in CI before it is promoted. Each step covers a gap the others leave open.

Troubleshooting reference

SymptomLikely causeWhat to check
docker pull fails, curl https://host/v2/ succeedsOS store updated after daemon start, or CA only in OS store and token host differsRestart Docker; add per-registry certs.d as well
curl and docker pull both fail with unknown authorityCA not trusted anywhere, or missing intermediateopenssl s_client -showcerts; count the certificates sent
docker pull works, docker build fails on FROMBuildKit docker-container driver does not see host certs.dbuildkitd.toml with ca, recreate the builder
Works on your laptop, fails in CIRunner image lacks the CA, or runner goes through an inspection proxyCompare issuer from openssl s_client on both
Pod in ImagePullBackOff with x509Node runtime lacks CAcrictl pull on the node; config_path and hosts.toml
imagePullSecrets added, still x509Secret is authentication, not trustFix node CA trust instead
Public registries fail with unknown authorityTLS inspection proxy re-signing trafficInstall proxy root deliberately, or bypass inspection for registry hosts
certificate is valid for X, not YHostname not in SANsPull with the certificate's name, or reissue
legacy Common Name fieldNo SAN in the certificateReissue with subjectAltName
certificate has expired or is not yet validExpired certificate or wrong host clockCheck notAfter and the host's NTP status
Podman fails, Docker works on the same hostDifferent trust directoriesAdd /etc/containers/certs.d/<host>:<port>/ca.crt
ctr pull fails, crictl pull worksctr ignores daemon config_pathPass --hosts-dir /etc/containerd/certs.d

Fix it without waiting for upstream

Whatever the vulnerability, most of the risk sits in two gaps. The first is the time between a fix being published and that fix actually running in your production builds. The second, and worse, is the time before any fix exists at all, while a maintainer works out a patch and a release schedule. Waiting through both gaps is a habit, not a requirement. Safeguard gives teams three ways to close them.

1. Autonomous remediation: pull requests, not tickets

When a fixed version exists, Griffin, Safeguard's security model, opens a policy gated pull request that moves the affected dependency to the safe version, and your own CI runs your tests on it before anyone merges. You can run it on one repository or in bulk across every project that carries the vulnerable version. Remediation categories include dependencies, licenses, security posture and findings, and each one is set separately. Reachability analysis ranks the vulnerable code paths your application actually calls first, so the first pull requests you merge are the ones that matter most. Auto-merge is a separate switch and stays off by default, so your team decides what merges on its own and what still needs a human approval.

2. Gold Open Source: the safe version in seconds, free

gold.safeguard.sh is free and needs no login. Search any CVE or package to see affected and fixed versions, CVSS, EPSS, CISA KEV status and a recommended safe version, or use the "Am I affected?" checker. The same data is available through a free read API, README status badges and RSS threat feeds.

3. Gold Registry: start from components that are already fixed

Safeguard Gold rebuilds patched versions of open-source packages and images from source, signs each build, attaches an SBOM, SLSA provenance and VEX, and serves them from registry.safeguard.sh to your organization. Every Gold artifact is held continuously to zero exploitable critical or high CVEs and a completed license check, with signed build provenance (signed with Safeguard's key and verifiable with cosign, SLSA Build Level 3 as the design target) and a public evidence page you can hand to an auditor. An artifact loses its Gold badge if it regresses. Teams can point their own package directory at the registry so hardened equivalents resolve without changing how developers install anything.

When upstream has no fix yet, or will not ship one, Safeguard first works to get the fix accepted upstream. If the issue is severe or upstream will not move, Safeguard builds a fork, fixes the vulnerability there and publishes a hardened Gold build, so you are not stuck on a maintainer's release schedule. You can also request a hardened build of a specific package. Ask which packages it covers before you plan around it.

Your situationWhat to use
A fixed version existsAutonomous remediation opens the upgrade pull request; Gold Open Source confirms the safe version
You need to know today whether you are affectedGold Open Source lookup and "Am I affected?" checker, free and without login
You want vulnerable versions to stop shippingSafeguard policies and gates in CI
Upstream has no fix yet, or will not ship oneA custom Gold request; fork and patch
You want to start clean on new projectsPull hardened components and images from Gold Registry

One honest limit: when the vulnerable code lives in a vendor appliance, a hosted service or an operating system runtime you do not build yourself, the fix still has to come from that vendor or distribution. The steps above apply to the open source dependencies and container images you build and ship, which is where most teams carry the largest and least tracked share of their exposure.

See how Safeguard would help

The demo below is a real Safeguard CLI session. It shows the CLI being installed, the assistant asked to fix a dependency finding, and a scan run to confirm the result. The session uses an npm project, but the same flow applies to the application dependencies inside the images you pull and build: fix the finding in source, rebuild, and let the image scan in CI confirm it.

Click through it at your own pace, or open it full screen.

This demo sets up the Safeguard local runner on your own machine, approves it with a device code and then scans from the terminal. For a platform team it is a way to scan a service repository where the code lives, before an image is ever built or pushed to a registry.

Click through it at your own pace, or open it full screen.

The last demo connects Claude to the Safeguard MCP server with OAuth and chooses which tools Claude can use. Once connected, you can ask about the findings, images and dependency paths behind the services you deploy from the assistant you already use.

Click through it at your own pace, or open it full screen.

To try it on your own images, download Safeguard and follow the container image guide.

FAQ

How do I make docker pull ignore certificate errors?

You can add the registry to insecure-registries in daemon.json and restart Docker, but you should not treat that as a fix. It disables server authentication for that registry and lets Docker fall back to plain HTTP. The safe fix is to trust the registry's CA: put it at /etc/docker/certs.d/<host>:<port>/ca.crt on each Docker host, or add it to the OS trust store and restart Docker.

What does "x509: certificate signed by unknown authority" mean?

The client could not build a certificate chain from the registry's certificate to a root CA it trusts. Either the issuing CA is not in the trust store of the process doing the pull, or the server is not sending the intermediate certificates needed to connect its leaf certificate to a trusted root.

Do I need to restart Docker after adding a CA?

For the per-registry directory under /etc/docker/certs.d, the distribution documentation says no restart is needed. For the OS trust store (update-ca-certificates or update-ca-trust), restart the Docker daemon so it reloads its roots. On Docker Desktop, restart Docker Desktop after adding the CA to the macOS keychain or the Windows certificate store.

Where do I put the CA certificate for containerd or Kubernetes?

In a hosts.toml under the directory named by config_path in containerd's configuration, conventionally /etc/containerd/certs.d/<host>:<port>/hosts.toml, with a ca field pointing at the CA file. Set config_path under the CRI plugin's registry section and restart containerd once. Do this on every node, preferably in the node image or bootstrap.

Will imagePullSecrets fix an x509 error in Kubernetes?

No. Pull secrets provide credentials so the registry can authenticate you. An x509 error means the node's container runtime cannot authenticate the registry, which happens before credentials are used. Fix CA trust on the nodes.

Why is an insecure registry a supply chain risk?

Without server authentication, anyone on the network path can impersonate the registry, serve a different image under a trusted tag, and collect the credentials you send. Because base images feed every build, a single intercepted pull on a CI runner can flow into signed, promoted artifacts. NIST SP 800-190 lists insecure connections to registries as a container registry risk.

Quick check

You added your company CA with update-ca-certificates, and curl to the registry now works, but docker pull still fails with unknown authority. What is the most likely reason?

The Docker daemon loaded its root certificates when it started and has not been restarted. Restart Docker, or add the CA to /etc/docker/certs.d/<host>:<port>/ca.crt, which the daemon reads per registry.

openssl s_client -showcerts against your registry prints a single certificate whose issuer is "Example Corp Issuing CA 2". Your hosts trust "Example Corp Root CA". Where should you fix this?

On the server. It is sending only the leaf and omitting the intermediate, so clients cannot connect the leaf to the root they trust. Configure the registry or its load balancer to send the full chain.

A pod is stuck in ImagePullBackOff with an x509 error. A colleague suggests adding the CA to a ConfigMap mounted into the pod. Will that work?

No. The image is pulled by the node's container runtime before the pod's containers exist. The CA has to be trusted by containerd (or the runtime in use) on the node, through hosts.toml or the OS trust store.

A cosign command in your pipeline uses --insecure-ignore-tlog. Has it disabled TLS certificate verification?

No. That flag tells cosign not to require a transparency log entry for the signature. The signature is still checked against the key you supply, and TLS to the registry is still verified using the OS trust store.

Sources

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

We email you a link to confirm, and only then add you to the list. Unsubscribe in one click. Privacy

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.