# Docker Hub pull rate limit in GitHub Actions

> Fix the Docker pull rate limit in GitHub Actions: authenticate the pull that fails, count what the job really pulls, and put a cache in front of Hub.

Source: https://latchkey.dev/learn/failures/docker-hub-pull-rate-limit-in-ci  
Updated: 2026-09-20

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.

```Actions log, pull step, Docker 29.7.2
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
```

## Common 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

1. Create a Docker Hub access token and store it as a repository or org secret.
2. Put the login step first, before checkout if a container action runs early.
3. Confirm it applied by pulling one image in the same job and reading the log.

```.github/workflows/ci.yml
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 -d
```

### Authenticate 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.

```Terminal
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.

```daemon.json
# /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.

```Terminal
# 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-bookworm
```

## 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.

## The 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.

```.github/workflows/ci.yml
- name: What this job pulls
  run: |
    grep -h "^FROM" Dockerfile* | awk '{print $2}'
    grep -h "image:" .github/workflows/*.yml | sort -u || true
```

## Signing 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.

```.github/workflows/ci.yml
- 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](/learn/failures/docker-registry-mirror-for-github-actions).

## FAQ

### Did the Docker Hub rate limit affect GitHub Actions?

Yes, and it still does: hosted runners pull from shared egress addresses and the anonymous limit is counted per address, so your job can be refused by a quota other people spent. Authenticating moves the count to your account, which is why it is the first fix.

### How do I resolve a Docker Hub rate limit on a self-hosted runner?

Log the daemon in once on the machine so every job inherits the credential, and put a pull-through cache in front of Hub for the images the fleet pulls repeatedly. A self-hosted runner has the advantage: its daemon configuration persists between jobs, so both changes are made once rather than per workflow.

### How many pulls does an authenticated Docker Hub account get?

Docker documents 200 pulls per 6 hours for an authenticated Personal account, against 100 per 6 hours for an anonymous client counted per IPv4 address or IPv6 /64 subnet. Paid plans are unlimited. Read the current numbers from Docker before sizing a fleet around them.

### Does pulling from GHCR count against the Docker Hub limit?

No. The limit belongs to Docker Hub, so an image on GHCR, ECR, Artifact Registry or your own registry is outside it. That is what makes copying your base images elsewhere a good trade: it removes the failure rather than retrying it.

## References

- [Docker Hub: usage and pull rate limits](https://docs.docker.com/docker-hub/usage/)
- [docker/login-action](https://github.com/docker/login-action)
- [Docker: run a registry as a pull-through cache](https://docs.docker.com/docker-hub/image-library/mirror/)
- [GitHub-hosted runners: standard runner specifications](https://docs.github.com/en/actions/reference/runners/github-hosted-runners)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
