11 KiB
How container registries count image pulls
A GET request to the manifest endpoint is the universal unit of a "pull," but every registry counts differently. The OCI Distribution Specification deliberately says nothing about pull counting, rate limiting, or metrics — these are left entirely to each registry implementation. Docker Hub, the most consequential registry for pull counting, defines a pull as a GET /v2/<name>/manifests/<reference> request; HEAD requests and blob downloads do not count. Other registries diverge significantly: AWS ECR throttles per-API-endpoint, Google Artifact Registry counts raw HTTP requests against a project quota, Azure ACR tracks ReadOps per SKU tier, and Quay.io essentially imposes no pull-specific limits at all.
The OCI spec intentionally stays silent on pull counting
The OCI Distribution Specification (v1.1.1) defines "Pull" only as a workflow category — the highest-priority conformance tier that all registries must support. The spec describes the process: "The process of pulling an object centers around retrieving two components: the manifest and one or more blobs. Typically, the first step in pulling an object is to retrieve the manifest." It defines four HTTP endpoints in this category: GET and HEAD on both /v2/<name>/manifests/<reference> and /v2/<name>/blobs/<digest>.
Critically, the spec contains zero guidance on how to count pulls, implement rate limiting, define deduplication windows, or report usage metrics. There are no rate-limit response headers, no 429 status codes, and no throttling semantics in the specification. A search of the opencontainers/distribution-spec GitHub repository reveals no open or closed issues proposing standardization of pull counting — the community treats this as firmly out of scope for a wire protocol specification. Every registry is free to define "pull" however it chooses.
At the protocol level, a single docker pull command generates a cascade of HTTP requests: one or more manifest GETs, plus one GET per blob (config + layers). For a multi-arch image, an additional manifest GET is required for platform resolution. The Docker daemon is somewhat intelligent here — it issues a HEAD request first to check the digest, and only proceeds to a GET if the image has changed.
Docker Hub counts manifest GETs in a 6-hour sliding window
Docker Hub's pull counting is the most consequential and best-documented system. The official documentation states: "Using GET emulates a real pull and counts towards the limit. Using HEAD won't." Only GET requests to /v2/*/manifests/* are counted. Blob/layer downloads (/v2/*/blobs/*) do not count. Even if all layers are already cached locally and nothing is actually downloaded, the manifest GET still counts as a pull.
Rate limits operate on a 6-hour (21,600-second) sliding window:
- Unauthenticated users: 100 pulls per IPv4 address (or per /64 IPv6 subnet)
- Personal accounts (authenticated): 200 pulls per user
- Pro, Team, and Business accounts: unlimited
The response headers ratelimit-limit: 100;w=21600, ratelimit-remaining, and docker-ratelimit-source allow clients to monitor their status. When exceeded, Docker Hub returns HTTP 429.
There is no deduplication for rate limiting. Every manifest GET counts individually, even for the same image pulled repeatedly. As one analysis confirmed: "If you execute docker pull alpine twice, you come two steps closer to exhausting your rate limit. Even if on the second command execution no image was transferred, two pull requests referring to the same image tag count as two and not one." However, the Docker daemon's built-in behavior mitigates this: it sends a HEAD request first (which doesn't count), compares the digest locally, and only issues a GET if the image has changed. Tools that bypass this optimization and issue GETs directly will consume quota needlessly.
An important distinction: the pull count statistic displayed on Docker Hub image pages (the cumulative "10M+" counter) uses a different tracking system than rate limiting. The dashboard counter represents all-time manifest fetches, is not real-time, and has known quirks — for instance, GitHub issue docker/hub-feedback#2182 reports the counter incrementing by 4 per pushed tag. Docker's usage dashboard separately tracks "version_checks" (HEAD requests) and "pulls" (GET requests) in exportable CSV data.
Multi-arch images count as one pull per architecture
When pulling a multi-arch image (OCI image index or Docker manifest list), the client first GETs the manifest list, then GETs the platform-specific manifest, then downloads blobs. Docker Hub groups the manifest list GET and the platform-specific manifest GET together as one pull per architecture. A Docker community moderator confirmed: "for multi-arch images 1 request contains one on the index and one on the platform specific manifest so basically 20 requests would be 10 pulls." The official docs state: "A pull for a multi-arch image will count as one pull for each different architecture."
This has significant implications for CI/CD. A docker buildx build --platform linux/amd64,linux/arm64 --push command pulls base images for each platform, so a FROM directive referencing a Docker Hub image will consume two pulls (one per architecture). Matrix CI strategies that build many platform combinations can exhaust rate limits quickly.
The docker buildx imagetools create command — used to assemble manifest lists from existing platform-specific images — also counts against rate limits. Buildx maintainer tonistiigi confirmed: "Yes, pulling down a manifest is what impacts the rate limit counter." Creating a manifest list from 4 platform digests produces at least 4 manifest GETs. Similarly, docker manifest inspect performs a GET and counts as a pull; there is no HEAD-only alternative.
For GitHub Actions specifically, GitHub-hosted runners pulling public Docker Hub images are exempt from rate limits due to an IP whitelisting agreement with Docker. Self-hosted runners are not exempt. When using buildx's docker-container driver, credentials must be explicitly passed to the builder container — a common misconfiguration that causes builds to hit unauthenticated rate limits even when the user has logged in.
Each registry takes a fundamentally different approach
No two registries count pulls the same way. The differences are architectural, not just numerical.
GitHub Container Registry (ghcr.io) bills by bandwidth transferred, not request counts. Public container pulls have no apparent rate limit (or an extremely generous one — one user observed an internal threshold of ~44,000 requests/minute). GHCR displays no public pull counter. Pulls from GitHub Actions using GITHUB_TOKEN are completely free and uncounted. The rate limit documentation is minimal, with a notable open issue (github/docs#24504) requesting better documentation.
AWS ECR uses per-API-endpoint token bucket throttling — a fundamentally different model. Each registry API has its own rate: BatchGetImage (manifest retrieval) allows 2,000 requests/second, while GetDownloadUrlForLayer allows 3,000/second. These are per-account, per-region limits using a burst-capable token bucket. All quotas are adjustable via AWS Service Quotas. There is no single "pull count" — each API call counts against its respective endpoint quota.
Google Artifact Registry counts every HTTP request against a per-project, per-region quota of 60,000 requests/minute. Google's docs explicitly state: "A Docker pull or push usually makes multiple HTTP requests, so quota is charged for each request." A pull of an image with 5 layers could consume 6-7 quota units. Remote repositories acting as pull-through caches have separate upstream limits (e.g., 600 reads/minute from Docker Hub per organization per region).
Azure Container Registry tracks ReadOps per minute on a SKU-tiered model: Basic (~1,000/min), Standard (~3,000/min), Premium (~10,000/min). Microsoft documents that "a docker pull translates to multiple read operations based on the number of layers in the image, plus the manifest retrieval." Each layer GET and manifest GET count as separate ReadOps. Bandwidth is throttled independently (30-100 Mbps depending on tier). HEAD requests count as ReadOps.
Quay.io is the most permissive: it "does not restrict anonymous pulls against its repositories and only rate limits in the most severe circumstances to maintain service levels (e.g., tens of requests per second from the same IP address)." There is no pull-specific rate limit — only a general abuse-prevention API rate limit of a few requests per second per IP. No public pull counter exists.
| Registry | Unit of counting | Effective limit | HEAD counts? | Deduplication | Public counter |
|---|---|---|---|---|---|
| Docker Hub | Manifest GET | 100-200/6hrs (free) | No | None | Yes (all-time) |
| GHCR | Bandwidth | ~44K req/min (observed) | Unknown | None | No |
| AWS ECR | Per-API calls | 2,000-3,000/sec | N/A (AWS API) | None | No |
| Google AR | All HTTP requests | 60,000/min/project | Likely yes | None | No |
| Azure ACR | ReadOps | 1,000-10,000/min by SKU | Yes | None | No |
| Quay.io | API requests (abuse only) | ~tens/sec/IP | Likely yes | None | No |
Practical implications and key takeaways
The fragmentation in pull counting creates real operational complexity. Docker Hub's model — counting only manifest GETs, excluding HEAD requests and blob downloads, using a 6-hour window with no deduplication — is the most restrictive and the most precisely defined. Every other major registry offers orders-of-magnitude more headroom, but counts differently: some charge per HTTP request (including blob downloads), others per API call, others per bandwidth.
For teams optimizing pull behavior, the critical insight is that HEAD requests are the escape hatch on Docker Hub. Tools like Watchtower switched to HEAD-based digest checks specifically to avoid incrementing pull counts. The Docker daemon itself uses this optimization — issuing HEAD first, then GET only if needed. For multi-arch workflows in CI, each platform multiplies the pull cost for base images, making authenticated access or registry mirrors essential for high-volume pipelines. The docker buildx imagetools create and docker manifest inspect commands both consume pulls, a fact confirmed by maintainers but poorly documented.
The absence of any OCI-level standard means this landscape will likely remain fragmented. Docker Hub's definition — manifest GET as the unit of pull — has become a de facto convention that influences how the community thinks about pulls, but registries that charge per HTTP request (Google, Azure) or per bandwidth (GHCR) are measuring fundamentally different things under the same word.