Skip to main content
RunBook Academy

Docker & ContainersXXXI Β· TroubleshootingImage pull

Image pull failures β€” registry, auth, network

Foundation⏱ ~26 mindockercurl

What you'll learn

  • Diagnose image pull failures
  • Identify registry, auth, and network issues
  • Configure pull-through mirrors
  • Map a registry HTTP status code to a specific cause
  • Distinguish a rate limit from an authentication failure from a missing tag

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

Not yet marked complete on this device.

Pull failures happen at three layers: the registry, authentication, and network. Each has a distinct symptom.

Underneath every one of them is an HTTP exchange with a registry, and the status code of that exchange is the diagnosis. The CLI paraphrases it, often unhelpfully β€” pull access denied is printed for both β€œyou are not logged in” and β€œthis repository does not exist”, because a registry returns the same 401 for both by design so that private repository names cannot be enumerated.

The errors as the CLI prints them

docker pull myorg/myapp:1.0.0
# Error response from daemon: pull access denied for myorg/myapp
# (authentication required or insufficient_scope)
# β†’ auth, repo permissions, or the repo does not exist

# Error response from daemon: Get "https://registry.example.com/v2/": dial tcp: lookup registry.example.com: no such host
# β†’ DNS resolution failure, on the HOST, not in a container

# Error response from daemon: Get "https://registry.example.com/v2/": net/http: TLS handshake timeout
# β†’ network path, MTU, or a middlebox

# Error response from daemon: Get "https://registry.example.com/v2/": x509: certificate signed by unknown authority
# β†’ the daemon does not trust the registry's CA

# toomanyrequests: You have reached your pull rate limit.
# β†’ HTTP 429; see the rate-limit section

# manifest for myorg/myapp:1.0.0 not found: manifest unknown
# β†’ HTTP 404; the tag does not exist for this platform

The status code is the diagnosis

StatusRegistry meansFix
401 UnauthorizedNo credentials, or the token expireddocker login; check the credential helper
403 ForbiddenAuthenticated, but this identity lacks the scopeRepository permissions, not credentials
404 Not FoundRepository or tag does not exist for the requested platformCheck the tag, and check the platform
429 Too Many RequestsRate limitedAuthenticate, mirror, or wait out the window
5xxRegistry is unwellRetry with backoff; check the registry’s status
TLS error, no statusThe connection never became HTTPCA trust, proxy interception, MTU

The 401 vs 403 distinction is the useful one and the CLI hides it. 401 means the registry did not accept who you are. 403 means it accepted who you are and refused what you asked for β€” so re-running docker login will not help and the fix is a permissions change on the repository.

Read-only / Safeget the real status code
REGISTRY=registry.example.com
REPO=myorg/myapp
TAG=1.0.0

# 1. Is the registry API reachable at all? An unauthenticated /v2/ probe
#    returns 200 or 401 on a healthy registry; anything else is the answer.
curl -sS -o /dev/null -w 'v2 probe: %{http_code}\n' "https://$REGISTRY/v2/"

# 2. The full response headers, including the auth challenge
curl -sSI "https://$REGISTRY/v2/$REPO/manifests/$TAG"

# 3. What authentication does it want?
curl -sSI "https://$REGISTRY/v2/" | grep -i 'www-authenticate'

# 4. Certificate chain, if TLS is the suspect
echo | openssl s_client -connect "$REGISTRY:443" -servername "$REGISTRY" 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates

Step 3 returns the Www-Authenticate header, which names the token service the daemon will be redirected to. When a pull fails inside a corporate network and the registry itself is reachable, the token service frequently is not β€” it is often a different hostname on a different CDN, and a firewall rule that allowlists the registry alone will produce a failure that looks exactly like bad credentials.

Rate limits, precisely

Docker Hub’s published limits:

AccountLimitWindow
Unauthenticated100 pulls β€œper IPv4 address or IPv6 /64 subnet”6 hours
Personal (authenticated)2006 hours
Pro, Team, BusinessUnlimitedβ€”

Two details in that table do the diagnostic work.

The unauthenticated limit is per IP address or /64 subnet, not per host. An office or a CI cluster behind one NAT gateway shares a single allowance, so a single developer can exhaust it for everyone, and the machine that fails is not the machine that caused it. This is why β€œit works on my laptop” and β€œit fails in CI” coexist so often.

The window is 6 hours, which is a genuine reason β€œwait and retry” is not absurd β€” but it is a bad plan during a deploy, and it does nothing about the next occurrence.

Read-only / Safecheck your remaining allowance
# Anonymous token for a public repo
TOKEN=$(curl -sS \
'https://auth.docker.io/token?service=registry.docker.io&scope=repository:ratelimitpreview/test:pull' \
| sed -E 's/.*"token":"([^"]+)".*/\1/')

# HEAD the manifest and read the rate-limit headers
curl -sSI -H "Authorization: Bearer $TOKEN" \
https://registry-1.docker.io/v2/ratelimitpreview/test/manifests/latest \
| grep -i ratelimit

Illustrative output:

ratelimit-limit: 100;w=21600
ratelimit-remaining: 20;w=21600
docker-ratelimit-source: 192.0.2.1

w=21600 is the 6-hour window in seconds. docker-ratelimit-source is the address the limit is being counted against β€” and when that address is your NAT gateway rather than your host, you have just proved the shared allowance theory without arguing about it.

The 404 that is really a platform mismatch

docker pull myorg/myapp:1.0.0
# no matching manifest for linux/arm64/v8 in the manifest list entries

The tag exists. The registry has it. It simply has no image for your host’s platform, and a multi-platform manifest list that lacks your entry produces a 404-shaped failure.

Read-only / Safewhat platforms does this tag have
REPO=myorg/myapp
TAG=1.0.0

# Every platform in the manifest list
docker manifest inspect "$REPO:$TAG" \
| python3 -c 'import json,sys; d=json.load(sys.stdin); [print(m["platform"]) for m in d.get("manifests",[{"platform":d.get("architecture")}])]'

# What platform is this host asking for?
docker info --format '{{.Architecture}} {{.OSType}}'

# Force a specific platform to confirm the theory
docker pull --platform linux/amd64 "$REPO:$TAG"

If --platform linux/amd64 succeeds on an arm64 host, the diagnosis is closed: the image is single-platform and your host is the wrong one. The pulled image will then fail to run with exec format error unless binfmt emulation is installed, which is a different lesson and a different problem β€” but at least it is now a known one.

Where the credentials actually live

Read-only / Safecredential audit
# Which registries does this user have entries for?
cat ~/.docker/config.json 2>/dev/null | python3 -m json.tool

# Is a credential helper in use? If so the file holds no secret and
# 'auths' will list the registry with no 'auth' key.
grep -o '"credsStore"[^,]*' ~/.docker/config.json 2>/dev/null

# Log in to a specific registry (prompts; do not pass a password on the
# command line, it lands in shell history and in ps output)
docker login registry.example.com

# Verify by asking the registry for something only an authenticated
# identity can see
docker manifest inspect registry.example.com/myorg/private-app:latest >/dev/null \
&& echo 'auth OK' || echo 'auth FAILED'

Registry mirrors

For rate-limited or bandwidth-constrained environments, configure a pull-through cache. The daemon tries the mirrors first and falls back to the original registry.

Configuration changepull-through mirror
sudo tee /etc/docker/daemon.json >/dev/null <<'JSON'
{
"registry-mirrors": ["https://registry-mirror.example.com"]
}
JSON

sudo dockerd --validate --config-file=/etc/docker/daemon.json
sudo systemctl reload docker

# Verify the daemon accepted it
docker info --format '{{.RegistryConfig.Mirrors}}'

# Verify it is actually being used: pull something absent locally and
# watch the mirror's access log, or compare timings
docker image rm alpine:3.20 2>/dev/null || true
time docker pull alpine:3.20

registry-mirrors is one of the keys the daemon reloads on SIGHUP (which is what systemctl reload docker sends), so this does not require a restart and does not disturb running containers.

Two limits worth knowing before you rely on it: registry-mirrors applies to Docker Hub only β€” mirrors for other registries are configured per-registry in containerd’s host configuration, not with this key. And docker info confirming the mirror is configured does not confirm it is being used; only the mirror’s own access log or a timing difference does.

The diagnostic tree

flowchart TD
  S[Pull failed] --> Q0{"curl https://REGISTRY/v2/ succeeds?"}
  Q0 -- "TLS/DNS/timeout" --> C0["Below HTTP: DNS, routing,<br/>CA trust, proxy, MTU"]
  Q0 -- "gets a status" --> Q1{"Which status?"}
  Q1 -- 401 --> C1["Not authenticated:<br/>docker login, check WHICH user"]
  Q1 -- 403 --> C2["Authenticated, wrong scope:<br/>repository permissions"]
  Q1 -- 404 --> Q2{"Does the tag exist<br/>for your platform?"}
  Q2 -- no --> C3["Platform mismatch:<br/>docker manifest inspect"]
  Q2 -- yes --> C4["Wrong tag or repo name"]
  Q1 -- 429 --> C5["Rate limited: authenticate,<br/>mirror, or wait the 6h window"]
  Q1 -- 5xx --> C6["Registry unwell: retry,<br/>check its status page"]
  Q1 -- "200, but layers stall" --> C7["Blob store on a different host<br/>is blocked by proxy/firewall"]

Verification

Read-only / Safeverify the pull path
#!/usr/bin/env bash
set -euo pipefail
REF=registry.example.com/myorg/myapp:1.0.0

# Remove the local copy so the pull is real, not a cache hit
docker image rm "$REF" >/dev/null 2>&1 || true

# Pull, and fail the script if it fails
docker pull "$REF" || { echo 'FAIL: pull failed' >&2; exit 1; }

# Confirm what we actually got: digest and platform
docker image inspect "$REF" \
--format 'digest={{index .RepoDigests 0}} platform={{.Os}}/{{.Architecture}}'

# And that it matches the host we are on
host_arch=$(docker info --format '{{.Architecture}}')
img_arch=$(docker image inspect "$REF" --format '{{.Architecture}}')
[ "$host_arch" = "$img_arch" ] || echo "WARN: host $host_arch, image $img_arch"

echo OK

The docker image rm first is the part that makes this a verification rather than a formality. A docker pull that returns instantly because the image is already local proves nothing about the registry, the credentials or the network.

Knowledge check

Knowledge check Β· 7 questions

  1. Q1. `docker pull` fails with `toomanyrequests: You have reached your pull rate limit`. The most appropriate response is:

  2. Q2. The registry returns 403 rather than 401. What does that change about the fix?

  3. Q3. A pull authenticates, fetches the manifest, then stalls with layer progress bars frozen part-way. What is the most likely cause?

  4. Q4. A pull works from your shell but fails from a systemd-managed deploy on the same host. Which explanations fit? Select all that apply.

  5. Q5. You get `x509: certificate signed by unknown authority` pulling from an internal registry. Which responses are appropriate? Select all that apply.

  6. Q6. `docker pull` failures can be diagnosed by inspecting the registry's HTTP responses.

  7. Q7. The unauthenticated Docker Hub pull limit is counted per host, so one machine cannot exhaust another machine's allowance.

Passing score: 75%. Answers are checked in this browser.