Docker Hub pull rate limit in GitHub Actions
A Docker pull rate limit in GitHub Actions is an HTTP 429 on an image your job is pulling, and for anonymous pulls it is counted per address, which on hosted runners is an address you share with strangers. Authenticate the pull to move the count onto your own account, then stop pulling from Docker Hub on every job.


What this error means
A step dies while it is resolving an image, before anything of yours runs. The wording depends on which Docker you are on, and the two look nothing alike. The classic one is an errcode line that starts with "toomanyrequests" and then says you have reached your pull rate limit, with a link to Docker's page about increasing it. The other belongs to Docker 29 and the containerd image store, where the daemon reports the request it made and the status it got back, and no errcode text appears at all. Both are the same HTTP 429 from the same registry. It arrives in other clothes too: a Dockerfile that never gets past its FROM line, a Testcontainers setup error, a Kubernetes ImagePullBackOff, or a Docker-based action failing before your first step. The run below takes its 429 from a stub registry on loopback, because exhausting the real limit on demand is not something to do to a shared service; the daemon, the TLS, the resolve sequence and the message are real. Its retry then pulls node:20-slim from Docker Hub through the path the runner normally uses, rather than asking the stub again, so what the run evidences is the message and the wrapper's retry, not a rate-limit window that cleared.
Error response from daemon: unknown: failed to resolve reference "127.0.0.1:5001/library/node:20-slim": unexpected status from HEAD request to https://127.0.0.1:5001/v2/library/node/manifests/20-slim: 429 Too Many RequestsReproduced on a Latchkey runner
Docker version 29.7.2, build a7dcaa6, pulling 127.0.0.1:5001/library/node:20-slim
Error response from daemon: unknown: failed to resolve reference "127.0.0.1:5001/library/node:20-slim": unexpected status from HEAD request to https://127.0.0.1:5001/v2/library/node/manifests/20-slim: 429 Too Many Requests
[latchkey-bash-wrapper] BEGIN sidecar POST (boot_wait=30s max_time=320s url=http://localhost/diagnose socket=/run/latchkey-self-heal/sock)
[latchkey-bash-wrapper] END sidecar POST ok (attempts=1 http=200)
Docker version 29.7.2, build a7dcaa6, pulling node:20-slim
20-slim: Pulling from library/node
ff86ea2e5edc: Pulling fs layer
3c02fd806613: Pulling fs layer
e54aec64c365: Pulling fs layer
804d4d68057c: Pulling fs layer
64cfb949317c: Pulling fs layer
3c02fd806613: Download complete
e54aec64c365: Download complete
64cfb949317c: Download complete
31633b71bda5: Download complete
804d4d68057c: Download complete
ff86ea2e5edc: Download complete
c25f5f744a13: Download complete
e54aec64c365: Pull complete
ff86ea2e5edc: Pull complete
64cfb949317c: Pull complete
804d4d68057c: Pull complete
3c02fd806613: Pull complete
Digest: sha256:2cf067cfed83d5ea958367df9f966191a942351a2df77d6f0193e162b5febfc0
Status: Downloaded newer image for node:20-slim
docker.io/library/node:20-slim
pulled node:20-slim, 71390006 bytesThe numbers, and what counts against them
Docker documents the anonymous limit as 100 pulls per 6 hours per IPv4 address or IPv6 /64 subnet, and an authenticated Personal account as 200 per 6 hours. Paid plans are unlimited. The gap is smaller than most people assume, which is why signing in alone often moves the failure rather than removing it.
The other half is the count. A job pulls more images than its Dockerfile suggests: the base image in every stage, every services: container, every Docker-based action, every image a test harness starts, and the same set again in every matrix leg. Count them once and the number stops surprising you.
- name: What this job pulls
run: |
grep -h "^FROM" Dockerfile* | awk '{print $2}'
grep -h "image:" .github/workflows/*.yml | sort -u || trueCommon causes
The pull is anonymous and the address is shared
The ordinary case on hosted runners. Nobody signed in, so the count is against an address belonging to the fleet rather than to you, and the jobs that spent it were not yours. It is intermittent by construction: the same workflow passes when the window rolls.
You added a login step and the failing pull still is not covered
The wasted fix. A login step authenticates the runner daemon from that point on, which does nothing for an image pulled by a Docker-based action earlier in the job, by a Kubernetes node your job talks to, or by a harness with its own client. In our experience it is usually a login placed after the step that pulls.
The job pulls far more images than anyone counted
Base images per build stage, service containers, action images and harness images add up, and a matrix multiplies all of it. The list is never in one place, which is why the count is always wrong the same way.
Every leg pulls the same image again
A matrix running the same build against six versions pulls the same base six times, and a layer cache is not the same thing as a registry that is never asked. Pinning by digest keeps the build reproducible and does not cut the request count.
How to fix it
Authenticate before anything pulls
- Create a Docker Hub access token and store it as a repository or org secret.
- Put the login step first, before checkout if a container action runs early.
- Confirm it applied by pulling one image in the same job and reading the log.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: actions/checkout@v7
- run: docker compose up -dAuthenticate where the pull actually happens
A pull made by something other than the runner daemon needs its own credentials. Kubernetes needs an image pull secret on the service account, and a harness needs its daemon logged in. A Docker-based action pulls its own image before any step of yours runs, which no login step can cover; host it somewhere without the limit.
kubectl create secret docker-registry dockerhub \
--docker-server=https://index.docker.io/v1/ \
--docker-username="$DOCKERHUB_USERNAME" \
--docker-password="$DOCKERHUB_TOKEN"Put a cache in front of Docker Hub
A pull-through cache answers repeat pulls from a copy it keeps, so they never reach Hub and never count. It scales: it covers every image and every job at once rather than one workflow at a time. The measured setup is on the mirror page.
# /etc/docker/daemon.json on a runner you control
{ "registry-mirrors": ["https://mirror.internal.example.com"] }Pull fewer images
Copy the handful of base images your builds use into a registry without a pull limit and point the workflows there. Collapse matrix legs that differ only in a runtime version, and drop service containers the tests never touch. Every image removed is a request that cannot fail.
# mirror a base image once, then pull it from your own registry
docker pull node:22-bookworm
docker tag node:22-bookworm ghcr.io/acme/node:22-bookworm
docker push ghcr.io/acme/node:22-bookwormSigning in moves the count, it does not remove it
An authenticated pull is counted against the account rather than the address, which takes you off a limit shared with the whole runner fleet. It is the most valuable change on this page, and it is four lines.
It is not unlimited on a free account, though. A matrix pulling four images across sixty legs is over 200 whether or not anybody signed in, so authentication has to be paired with pulling less.
- uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- run: docker build -t app .Why your error may not match the one in every other write-up
Every article about this failure quotes the errcode wording, because for years that is what the daemon printed. On Docker 29 with the containerd image store the resolve path reports the HTTP status of the request it made and leaves the registry's error body out. Our run recorded that form on 29.7.2; the errcode form is what the library's samples carry from the older daemon.
The consequence is a search problem: looking for the errcode word in a log that carries only the status leads people to rule the rate limit out. Search for the status.
What the runner does about it
Latchkey carries DOCKER_PULL_RATE_LIMIT at confidence 0.96 for Docker Hub's own errcode wording, and its plan is a retry with a 60 second base backoff rather than the 2 seconds a 5xx gets, because the limit recharges on a clock. The library is candid about the case it gets wrong: a hand-rolled retry loop around docker pull leaves the same line behind after it succeeds, and healing that costs about three minutes of billed backoff before the step fails for its real reason.
The containerd wording is covered by a second entry, DOCKER_RATE_LIMIT_ANYHOST, and that one is in shadow mode at 0.85: it observes and reports, and it does not yet repair automatically. Our recorded run produced the containerd wording, so it is not evidence of an automatic repair and this page does not claim one. What retried that step was the wrapper's own retry rather than a pattern, because a shadow entry reports without repairing. What the runner does carry by default is the durable fix: its daemon is configured with a pull-through cache in front of Docker Hub, which is the subject of a Docker registry mirror for GitHub Actions.
How to prevent it
- Log in to Docker Hub in every workflow that pulls from it, as the first step.
- Keep the base images your builds depend on in a registry you control.
- Run a pull-through cache once the fleet is big enough to notice the limit.
- Review the image list when a matrix grows, because the pull count grows with it.