Build a Pull-Through Container Registry Cache with Zot

Put zot v2.1.23 in front of Docker Hub as an on-demand pull-through cache: the minimal sync config, the two ways to point clients at it, and measured cold/warm/direct transfer times from two runs, plus what the cache really costs on disk.

Docker Hub counts pulls per IP address: 100 every six hours without an account, 200 on a Personal plan, and a CI fleet sitting behind one NAT address spends that in an afternoon. This guide puts zot v2.1.23 in front of Docker Hub as an on-demand pull-through cache on a single VPS — one JSON config, one published port, and every pull after the first is answered from local disk instead of the upstream registry. It covers the exact limits and what counts as a pull, both ways to route a client through the cache, the measured cost of a miss versus a hit (cold 1.5–2.6 s, warm 0.21–0.26 s, direct 1.7–6.4 s across two runs), and why a single manifest request fills the whole image in the cache. Verified 2026-11-10.

What we are building

One container that speaks the Docker Registry v2 API to your machines and speaks it back to registry-1.docker.io when it does not have an image yet.

docker CLI  ──►  zot :5000  ──►  registry-1.docker.io  (only on a miss)
                    │
                    └─ /var/lib/registry   (blobs, one copy per digest)

The sync extension runs in onDemand mode: zot does not poll Docker Hub for tags, it waits until a client asks for library/nginx:1.29-alpine, fetches the manifest and the layers, stores them, and serves them. The second client that asks for the same tag gets bytes off local disk.

This is the cheap half of a private registry. It does not build images and it does not replace one — it makes repeats free, and it keeps working when the upstream registry rate-limits you on a cold start.

What you will learn

  • The published Docker Hub limits, what the API returns when you cross them, and which requests count as a pull
  • The config block that turns on on-demand caching for Docker Hub
  • The two client paths — a registry-prefixed image reference, or registry-mirrors on the daemon — and when each one is the right choice
  • How much slower a cache miss is than a direct pull, and how much faster a hit is, from two independent runs
  • Why the cache grows faster than the images you think you are pulling, and how to measure the disk it actually uses

Prerequisites

  • Docker Engine with Compose v2 (verified on 28.2.2, Compose 5.5.1)
  • A host with roughly 1 GB of RAM free and a disk you can grow — zot idled at 65 MiB for a 45 MB cache
  • No Docker Hub account required for public images; a paid account only helps for the pulls that still miss the cache

1. What Docker Hub actually counts

The limits are per six hours, and unauthenticated pulls are keyed to the client IP — an IPv4 address, or an IPv6 /64 subnet, so a whole IPv6 allocation shares one budget.

User type Pull rate limit per 6 hours
Business (authenticated) Unlimited
Team (authenticated) Unlimited
Pro (authenticated) Unlimited
Personal (authenticated) 200
Unauthenticated 100 per IPv4 address or IPv6 /64 subnet

A pull is one manifest, not one layer: a normal image costs one pull, and a multi-arch image costs one pull per architecture — which is why the same reference on an amd64 host and on an arm64 runner spend different budget. When you cross the limit the manifest request returns 429 with:

You have reached your pull rate limit. You may increase the limit by authenticating and upgrading: https://www.docker.com/increase-rate-limits

That message shows up in the CLI or in the daemon’s logs, which is why the failure looks like a broken tag to whoever is on call.

The arithmetic that makes a cache worth running: ten CI jobs pulling 15 images each is 150 pulls per pipeline wave, and two waves exhaust the unauthenticated budget for every machine behind that IP.

2. Run the cache

Two files. The config is the interesting one:

{
  "distSpecVersion": "1.1.1",
  "storage": {
    "rootDirectory": "/var/lib/registry"
  },
  "http": {
    "address": "0.0.0.0",
    "port": "5000",
    "compat": ["docker2s2"]
  },
  "log": {
    "level": "info"
  },
  "extensions": {
    "sync": {
      "enable": true,
      "registries": [
        {
          "urls": ["https://index.docker.io"],
          "onDemand": true,
          "tlsVerify": true,
          "retryDelay": "5m",
          "syncTimeout": "10m"
        }
      ]
    }
  }
}
  • compat: ["docker2s2"] is what makes the /v2/ routes behave the way the Docker client expects; it is what zot’s own Docker-compatibility example ships with.
  • onDemand: true caches when somebody asks, instead of mirroring on a schedule. false plus content rules turns this into a periodic mirror, which is a different tool for a different problem.
  • urls is the upstream entry point. https://index.docker.io is Docker Hub’s canonical host in zot’s own sync example and the one that works anonymously for public images — no credentials file needed.
  • retryDelay and syncTimeout bound what happens when the upstream is slow or flaky instead of letting a client hang.
  • zot verify /etc/zot/config.json prints config file is valid and exits 0 — worth running before you start the container, because a typo in this file is a container that exits on boot.

Then the compose file:

services:
  zot:
    image: ghcr.io/project-zot/zot:v2.1.23
    restart: unless-stopped
    ports:
      - "127.0.0.1:5000:5000"
    volumes:
      - ./config.json:/etc/zot/config.json:ro
      - zot-data:/var/lib/registry
    healthcheck:
      test: ["CMD", "/usr/local/bin/zot-linux-amd64", "healthcheck", "/etc/zot/config.json"]
      interval: 30s
      timeout: 5s
      retries: 3

volumes:
  zot-data:
docker compose up -d
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5000/v2/

200 on /v2/ with no credentials means the registry is up and serving anonymously. That is deliberate for a cache on loopback and a decision to revisit the moment the port is reachable from somewhere else. The healthcheck subcommand probes /livez, /readyz or /startupz (default readyz) and exits 0 on a 2xx response, so Compose marks the container unhealthy when the registry stops answering — a plain TCP check would not.

Pin the tag. ghcr.io/project-zot/zot:v2.1.23 is 248 MB on disk and its entrypoint is zot-linux-amd64 serve /etc/zot/config.json, so mounting your config at that path is all the wiring there is.

3. Pull through it

Put the cache’s address in an environment variable once, and a registry-prefixed reference routes every pull through it:

export CACHE=localhost:5000
docker pull $CACHE/library/nginx:1.29-alpine
docker image inspect $CACHE/library/nginx:1.29-alpine --format '{{.RepoTags}}'

Keep the library/ namespace for official images. Docker Hub serves those under library/, and naming it explicitly keeps the cached copy and the upstream path identical — the part of the reference that a bare nginx hides from you.

No insecure-registries entry is needed when the cache is on the local machine: the daemon treats localhost and 127.0.0.1 as insecure by default, which is why this setup starts working with nothing but the config above.

4. Or point the whole daemon at it

If you would rather not rewrite image references in every compose file and CI job, make the cache a mirror instead:

{
  "registry-mirrors": ["http://localhost:5000"]
}

That goes in /etc/docker/daemon.json, and the Docker daemon has to be restarted before it re-reads the file. Confirm it took:

docker info --format '{{.RegistryConfig.Mirrors}}'

The trade-off is scope and predictability. A mirror only applies to docker.io references, it is invisible in the reference the CLI prints, and whether a given pull went to the mirror or to Docker Hub is not something you can see from the client side. The prefixed reference is explicit: reading a compose file tells you whether that service pulls from the cache or straight from the internet. For a team, the mirror is easier; for debugging a rate limit at 2 a.m., the prefix is easier.

5. Cold versus warm, measured twice

Measured with a script that requests the manifest list, the amd64 manifest and then every blob sequentially — the same HTTP requests the Docker client makes, minus the local unpack. Two full runs, each with an empty cache, over alpine:3.22 (2 blobs, 3,794,986 bytes) and nginx:1.29-alpine (9 blobs, 26,000,518 bytes). Byte counts were identical on every path, so the only variable is time.

Path alpine:3.22 nginx:1.29-alpine
Direct from registry-1.docker.io 1.706 s / 1.758 s 5.146 s / 6.360 s
Through zot, cache miss 1.525 s / 1.878 s 2.241 s / 2.645 s
Through zot, cache hit 0.212 s / 0.222 s 0.240 s / 0.261 s

Three things worth reading out of that table:

  • A hit is roughly ten times faster than a direct pull on the small image and about twenty times on the larger one. That is what a cache is for, and it is also the number that decides whether your CI spends its minutes downloading Alpine for the fortieth time.
  • A miss was not slower than going direct. The naive model says the cache adds a hop, so a cold pull should cost more. It did not, for nginx: 2.2–2.6 s through a cold zot against 5.1–6.4 s straight from Docker Hub. The reason is in section 7 — zot starts pulling layers as soon as it sees the manifest, so by the time the client asks for layer five it is already on disk.
  • The spread inside a path is larger than the gap between paths on the small image. Do not read 1.525 versus 1.878 as a ranking; it is network noise on a 3.8 MB transfer. The 26 MB image separates cleanly, which is the case you care about.

One honest caveat: the measuring client was sequential and single-connection. Docker’s own client parallelises layer downloads, so on a real host both the direct and the cold-miss numbers come down; the hit ratio is the part that survives.

6. What it costs on disk

After both images, the store held 29,999,658 bytes (28.6 MiB) — and the same number to the byte in both runs, which is what you want from a cache: deterministic contents.

That is close to the sum of the two images’ layer bytes (29.8 MB), so these two did not share anything. The next image changed that. Pulling redis:7-alpine — 16,420,206 bytes of layers — grew the store by only 12,659,243 bytes. The ~3.76 MB difference is the Alpine base layer that was already there, and zot’s dedupe task hardlinks identical blobs across repositories instead of storing a second copy. du over the store counts a hardlinked blob once per scan, which is how the gap shows up.

A trap while measuring this: zot does not run as root, so du -sb on the data directory as your normal user fails with Permission denied on blobs/. Measure through a root helper container mounted on the volume, or you will report a cache size of 170 KB and believe it.

Memory at rest with 45 MB of images cached: 65.36 MiB and 65.37 MiB on two samples. CPU stayed at 0.17%, which is what a cache should cost when nobody is pulling.

7. A manifest request fills the whole image

The sync extension does not wait for the client to request layer three. Ask for the manifest and zot syncs the image. Measured twice, by fetching only the manifest list and the child manifest and then leaving the client idle:

Image Blobs requested by the client Layer bytes Store growth
redis:7-alpine 0 16,420,206 12,659,243 (6 s later)
busybox:1.37 0 2,211,973 2,243,024 (8 s later)

Two consequences worth planning around. First, a cache miss finishes fetching even if the client disconnects, so a cancelled CI job still warms the cache — useful, but it also means an aborted pull is not free on the upstream side. Second, disk usage tracks the number of distinct tags somebody asks for, not the number of images running in production. A moving tag like main, or a floating latest, adds a full image every time it is rebuilt.

Common problems

curl http://127.0.0.1:5000/v2/ refuses the connection. The port was not published. -p 5000:5000 in a docker run, or the ports: block above; the container’s own address is not reachable from the host without it.

The first pull after a rebuild is slow again, the rest are fine. Expected on a miss. If every pull is a miss, the volume is not mounted at /var/lib/registry and each container start begins with an empty store.

The cache is huge and nothing is pulling. Count tags, not images: one entry per tag per digest. Floating tags and per-commit tags in CI both multiply storage, and the eager sync in section 7 means a manifest request alone is enough to store the image.

du says the store is empty. Non-root ownership on blobs/, as above.

Anyone on the network can pull. An unauthenticated zot answers /v2/ with 200, as it did here on loopback. Publish it on 127.0.0.1 and reach it over an SSH tunnel or a private network, or terminate TLS and add auth in front of it. A cache with no auth and a public port is a free bandwidth relay wearing your IP.

The rate limit still bites. The cache only removes repeats. A cold cache that sees fifty distinct images in one pipeline still sends fifty manifests upstream. Warm the cache with your stable base images before the pipeline runs, or authenticate the cache’s pulls with a paid account.

Production checklist

  • Pin the zot tag and the config together; v2.1.23 here, not latest
  • Publish the port on 127.0.0.1 or a private interface, never 0.0.0.0 on a public VPS
  • Mount /var/lib/registry on a named volume so the cache survives a stack teardown
  • Alert on free disk, not on cache size — the cache grows with tag churn you do not control
  • Pick one client path (prefixed reference or daemon mirror) and use it consistently in compose files and CI
  • Keep the healthcheck subcommand wired up so a dead cache fails loudly instead of silently sending every pull upstream
  • Warm the base images your pipeline always uses after a deploy

Where to go from here

  • Add the other upstreams. The sync extension takes a list of registries, so the same block repeated for ghcr.io and quay.io caches everything your compose files pull, not just Docker Hub.
  • Push your own images to the same registry. zot stores them under the same data root and the client path does not change — a private registry for your own builds without running a second service.
  • Turn on the web UI and search in the same config file if you want to browse what is cached; both are extensions next to sync.
  • Put TLS in front the moment a second machine needs the cache — a reverse proxy on 443 with a real certificate, and the insecure-registries exception stops being part of the design.

How these numbers were measured

Two full rounds, each starting from an empty data directory and a freshly started container. Transfer times come from a Python client that requests the manifest list, the amd64 manifest and every blob in sequence, timed end to end; the same client points either at http://127.0.0.1:5000 or at https://registry-1.docker.io with an anonymous pull token, so both paths move identical bytes. Storage figures come from du -sb executed inside a root container mounted on the volume, and memory from docker stats --no-stream sampled twice. Images: ghcr.io/project-zot/zot:v2.1.23, alpine:3.22, nginx:1.29-alpine, redis:7-alpine, busybox:1.37. Docker Engine 28.2.2, Compose 5.5.1, Linux 6.17.

Related guides