If your CI pipeline suddenly fails with toomanyrequests on docker pull or FROM, Docker Hub is throttling the IP your runners share, not your code. The fastest fix is to authenticate the pull with docker login; the durable fix is to stop pulling the same base images from Docker Hub on every run, using a registry mirror or a pull-through cache. Which one you need depends on how many pulls you make and who owns the IP.
What does the error actually look like?
The text varies slightly by Docker version, but the shape is stable:
ERROR: failed to solve: ubuntu:24.04: failed to resolve source metadata for
docker.io/library/ubuntu:24.04: unexpected status from HEAD request to
https://registry-1.docker.io/v2/library/ubuntu/manifests/24.04: 429 Too Many Requests
toomanyrequests: You have reached your pull rate limit. You may increase the limit
by authenticating and upgrading: https://www.docker.com/increase-rate-limit
What tripped me up the first time: the job had passed for weeks and nothing in the repo changed. The limit is counted per source, not per project. Unauthenticated pulls are counted against the client IP, and hosted CI runners share IP ranges with many other tenants. Your pipeline can fail because a stranger's pipeline pulled too much. Once you authenticate, the count moves to your account.
Takeaway: a rate-limit failure that appears with no code change usually means the shared IP's quota ran out, not that your pipeline got heavier.
How do I check how many pulls I have left?
Docker documents a special repository, ratelimitpreview/test, that returns your remaining quota in response headers without spending a pull. Run this from the machine (or CI step) that is failing:
TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:ratelimitpreview/test:pull" | jq -r .token)
curl -sI -H "Authorization: Bearer $TOKEN" \
https://registry-1.docker.io/v2/ratelimitpreview/test/manifests/latest \
| grep -i ratelimit
You will see ratelimit-limit and ratelimit-remaining, each with a window (for example ;w=21600 for six hours). To see the authenticated numbers, pass your credentials to the token request with curl -u "$USER:$PAT". Comparing the two outputs is the quickest way to confirm that logging in actually changes your bucket.
The exact limits and windows have changed over the years and Docker has revised the free-tier numbers more than once, so read the current figures on Docker's pull-usage documentation rather than trusting any blog post, including this one (as of October 2026, I would not hardcode a number anywhere).
Takeaway: read ratelimit-remaining before changing anything, so you know whether you are fixing the right problem.
Fix 1: authenticate every pull (five minutes)
In GitHub Actions, log in before anything touches the registry. Use a personal access token with read-only scope, not your account password:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: false
Two gotchas. First, docker/login-action makes the login visible to Buildx builds, but a plain docker pull inside a nested container or a services: image may use a different credential path. Second, secrets are empty in pull requests from forks (I wrote about that separately), so fork PRs fall back to anonymous pulls and can still hit the limit.
Takeaway: logging in is the cheapest fix, but it only helps jobs that can see the secret.
Fix 2: stop pulling from Docker Hub at all
Most of the pulls in a typical pipeline are a handful of official base images: node, python, postgres, redis. Two options remove them from Docker Hub's accounting.
Use a mirror of the official images. Amazon publishes copies of Docker's official images on its public registry, so FROM public.ecr.aws/docker/library/node:22-slim pulls the same library image from a different provider. The limitation is that only the official library images are mirrored there, not arbitrary third-party images, and you depend on that copy staying in sync (check the tag you pin actually exists before you rewrite every Dockerfile).
Run a pull-through cache. A cache sits between your runners and Docker Hub, stores each layer once, and serves it to every later job. The open source registry image can do this in a few lines:
# config.yml for the registry:2 image
version: 0.1
storage:
filesystem:
rootdirectory: /var/lib/registry
http:
addr: :5000
proxy:
remoteurl: https://registry-1.docker.io
username: your-dockerhub-user
password: your-dockerhub-read-token
Then point the Docker daemon at it with "registry-mirrors": ["http://your-cache:5000"] in /etc/docker/daemon.json. Note that this mirror setting only applies to Docker Hub images, which is exactly the case you are fixing. Cloud registries offer managed versions of the same idea (Amazon ECR's pull-through cache can front Docker Hub when you give it credentials), which removes the server you would otherwise babysit.
The drawbacks are real. A self-run cache is another service that can go down and block every build, it needs disk cleanup, and on ephemeral hosted runners there is no private network to put it on. If your builds run on self-hosted runners in one network, a cache pays off quickly; on hosted runners, prefer the mirror or login.
Takeaway: a cache turns N pulls per day into one, but only if your runners can actually reach it.
Which option fits which setup?
| Situation | Best fix | Main drawback |
|---|---|---|
| Hosted CI, few images, secrets available |
docker login with a read-only token |
Fork PRs still anonymous |
| Hosted CI, mostly official base images | Mirror via public.ecr.aws/docker/library
|
Official images only; tag sync |
| Self-hosted runners, many pipelines | Pull-through cache | You run and patch another service |
| Already on AWS with ECR | ECR pull-through cache | Setup of credentials and repo prefixes |
| Publishing your own images too | Push to GHCR or ECR, keep Hub as a pull source | Migration of image names |
If you are choosing a place for your own images, GitHub's container registry is the lowest-friction choice when your code already lives on GitHub, since GITHUB_TOKEN can authenticate the push without another secret.
Takeaway: pick by where your runners live, not by which fix sounds most robust.
FAQ
Why does docker pull say toomanyrequests when I only pulled once?
Unauthenticated pulls are counted per IP address, so shared CI or office NAT addresses burn the quota across many users. Authenticating moves the count to your account.
Do HEAD requests count toward the Docker Hub rate limit?
Docker's documentation says manifest HEAD requests do not count as pulls, which is why the ratelimitpreview check above is free. The 429 in a build log can still appear on a HEAD when you are already over the limit.
How do I avoid Docker Hub rate limits without paying?
Authenticate with a free-account token, pin base images to a mirror such as the Amazon ECR public registry copy of the official images, and cache layers in CI so unchanged layers are not re-pulled.
Bottom line
If you are on hosted CI with a handful of images, add docker/login-action with a read-only token today and check ratelimit-remaining to confirm it worked. If most of your pulls are official images, move the FROM lines to a mirror and you are mostly done. Only build a pull-through cache when you run self-hosted runners at a scale where a mirror's gaps start to hurt, and plan for it being one more thing that can break your builds.
Top comments (0)