Docker & ContainersXII Β· Supply ChainRegistry trust
Registry trust β pulling from who you think you are
What you'll learn
- Trace the six steps of a pull and name what each one authenticates
- Diagnose a trust failure to the specific link that broke
- Trust a private CA correctly instead of disabling verification
- Explain why content addressing makes transport trust replaceable
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-12
When you docker pull nginx, six things happen in order:
registry-1.docker.iois resolved to an address via DNS.- A TLS connection is established.
- The certificate chain is validated β trusted issuer, hostname in the SAN, not expired.
- Authentication, if required: a token is obtained and presented.
- The manifest is requested.
- Each layer blob is downloaded.
Each step authenticates something different, and the single most useful thing to internalise is that steps 2 and 3 authenticate the server, not the image. A perfectly valid certificate on a registry that is serving a backdoored image passes every TLS check there is.
What each control actually protects
| Step | Protected by | Against |
|---|---|---|
| DNS resolution | Nothing, on its own | β |
| TLS handshake | Certificate validation | Talking to the wrong server |
| Authentication | Credentials and scoped tokens | Unauthorised push and pull |
| Manifest fetch | The digest, if you pinned one | Getting a different artefact |
| Layer download | Content addressing | Corrupted or substituted bytes |
| The image itself | A signature | An artefact your publisher did not produce |
DNS is worth a line of its own. Cache poisoning, a compromised resolver or a BGP hijack can point your pull at an attackerβs server. That is a real attack and TLS is the reason it usually fails: the attacker reaches step 2 and cannot produce a certificate your daemon accepts for that hostname.
Usually. It fails unless somebody has widened the trust store, added
the host to insecure-registries, or the attacker has obtained a
certificate a public CA will issue for a name they control.
Diagnosing a trust failure
The errors look similar in a pipeline log and mean very different things. Work out which link broke before you change anything.
$ docker pull registry.example.com/myorg/myapp:1.4.0Error response from daemon: Get "https://registry.example.com/v2/": x509: certificate signed by unknown authorityIllustrative output
That is a private CA the host does not know about, or an intercepting proxy. It is not a network problem and it is not the registry being down.
$ docker pull registry.example.com/myorg/myapp:1.4.0Error response from daemon: Get "https://registry.example.com/v2/": x509: certificate is valid for registry.internal.example.com, not registry.example.comIllustrative output
Usually a load balancer terminating TLS with the wrong certificate, or a CNAME added without reissuing. Occasionally it is the interesting case.
REGISTRY=registry.example.com
openssl s_client -connect "${REGISTRY}:443" -servername "$REGISTRY" </dev/null 2>/dev/null \
| openssl x509 -noout -issuer -subject -dates -ext subjectAltNameissuer=C=US, O=Let's Encrypt, CN=R11
subject=CN=registry.example.com
notBefore=Jul 14 08:11:02 2026 GMT
notAfter=Oct 12 08:11:01 2026 GMT
X509v3 Subject Alternative Name:
DNS:registry.example.comIllustrative output
The -servername flag matters: a registry behind a shared load
balancer selects its certificate by SNI, and omitting it gets you the
default certificate rather than the one your daemon will see.
REGISTRY=registry.example.com
getent hosts "$REGISTRY"
docker run --rm --network host busybox nslookup "$REGISTRY" 2>/dev/null | tail -5203.0.113.40 registry.example.comIllustrative output
A registry that resolves differently inside a container than on the host is the signature of a split-horizon DNS setup or a container DNS override, and it produces failures that look like intermittent registry outages.
Trusting a private CA
The correct fix for a certificate your daemon does not recognise is to
teach it the CA. The daemon looks for CA material in a directory named
for the registry β the documentation gives the layout directly: βa copy
of its CA certificate is placed on the Docker host at
/etc/docker/certs.d/myregistry:5000/ca.crt.β
REGISTRY=registry.example.com
sudo mkdir -p "/etc/docker/certs.d/${REGISTRY}"
sudo cp internal-ca.crt "/etc/docker/certs.d/${REGISTRY}/ca.crt"
sudo chmod 0644 "/etc/docker/certs.d/${REGISTRY}/ca.crt"
docker pull "${REGISTRY}/myorg/myapp:1.4.0"If the registry listens on a non-standard port, the directory name includes the port, exactly as in the documented example.
Mirrors and the trust they inherit
A registry-mirrors entry redirects Docker Hub pulls through a server
you nominate. That server now sees every pull you make and is in a
position to answer them.
docker info --format 'mirrors: {{.RegistryConfig.Mirrors}}'mirrors: [https://hub-cache.example.com/]Illustrative output
The mirror is a genuine trust dependency: an operator of the mirror can
serve different content for a tag, and nothing in the pull command or
the resulting docker images output would show it.
And here is the payoff of everything in this part of the course: pin by digest and that stops being true. Content addressing means the daemon verifies the bytes it received against the digest it asked for. A hostile mirror can refuse to serve you, and it cannot substitute. Trust in the transport becomes replaceable by trust in the content, which is a much smaller thing to have to defend.
Docker Content Trust is going away
Older guidance recommends DOCKER_CONTENT_TRUST=1, which enables
Notary v1 signature verification at pull time.
That mechanism is being retired. The documentation states: βDocker
Content Trust (DCT) is being retired. The Notary v1 service at
notary.docker.io will shut down on December 8, 2026.β
If you have DOCKER_CONTENT_TRUST=1 set anywhere β a CI image, a
profile script, a systemd unit β find it now rather than on the day it
starts failing:
printenv DOCKER_CONTENT_TRUST || echo 'not set in this shell'
grep -R 'DOCKER_CONTENT_TRUST' /etc/environment /etc/profile.d/ /etc/systemd/system/ 2>/dev/null \
|| echo 'not set persistently'not set in this shell
not set persistentlyThe replacement is Cosign signatures verified at deploy time, covered in the signing lesson. There is no drop-in daemon-level equivalent β enforcement moves into your admission controller or your deploy tooling, which is a real change in where the control lives.
Public registries and the names you type
Docker Hub is a public registry. Anyone can push to it. The
library/* namespace holds Docker Official Images, reviewed by
Docker; everything else is whoever registered the name.
The risks are less about cryptography than about typing:
- Typosquatting. A name one character away from a popular image, published by somebody else, waiting for a Dockerfile typo.
- The implicit
library/prefix.docker pull alpineis an official image.docker pull someuser/alpineis not, and the two differ by an easily overlooked prefix in a review diff. - Namespace transfer. A repository that was trustworthy for years can change hands. Nothing about the reference changes.
- Abandonment. Popularity is not maintenance, and a download count is a measure of the past.
None of those are defeated by TLS, by authentication, or by pulling from a mirror. They are defeated by pinning a digest you evaluated and by verifying a signature from a publisher you named.
Vendor registries
Cloud registries β AWS ECR, Google Artifact Registry, Azure Container Registry β authenticate through the cloud providerβs IAM rather than through a registry-local account.
REGION=us-east-1
ACCOUNT=123456789012
REGISTRY="${ACCOUNT}.dkr.ecr.${REGION}.amazonaws.com"
aws ecr get-login-password --region "$REGION" \
| docker login --username AWS --password-stdin "$REGISTRY"Login SucceededIllustrative output
This is a genuine improvement β the credential on the host is an instance role rather than a password, and it rotates automatically. It also moves the interesting question elsewhere: the audit is now of IAM policy, not of a registry user list, and the common finding is a role with pull permission scoped far more broadly than anybody intended.
- Fully qualify registry hostnames in anything that is not an interactive shell, so a reference means one thing everywhere.
- **Trust private CAs through
/etc/docker/certs.d/**, never throughinsecure-registries. - **Audit every host for
insecure-registriesand unexpectedregistry-mirrors** β both are invisible in the image reference. - Pin production references by digest, which makes transport integrity non-load-bearing.
- Verify a signature from a named publisher identity before deploying, since that is the only control that survives a compromised registry.
- **Retire
DOCKER_CONTENT_TRUSTusage** ahead of the Notary v1 shutdown rather than after it.
Knowledge check
Knowledge check Β· 5 questions
Q1. An attacker with push access publishes a backdoored image to your own registry, and you pull it over a valid TLS connection with correct authentication. Which control would have stopped you deploying it?
Q2. Why does an image referenced by digest make transport integrity non-load-bearing, while a tag reference does not?
Q3. Which are accurate about `insecure-registries` in daemon.json? Select all that apply.
Q4. `registry-auths` is the daemon.json key for storing registry credentials on the host.
Q5. Docker Content Trust is being retired, and there is no drop-in daemon-level replacement for pull-time signature enforcement.
Passing score: 75%. Answers are checked in this browser.