Suppose your CI runs on a handful of machines behind one office IP, and each pipeline wave pulls fifteen images across ten jobs. That is 150 pulls. Docker Hub gives an unauthenticated client 100 per six hours — keyed to the IPv4 address or the IPv6 /64 — so the second wave of the afternoon fails with a 429 and a message about upgrading, which the person on call reads as a broken tag.
A pull-through cache is the cheap answer: one container that speaks the Registry v2 API to your machines and speaks it back to Docker Hub only on a miss. The full build is in the guide below — one JSON config, one compose file, measured twice against zot:v2.1.23, verified 2026-11-10.
The numbers that surprised me
A hit is ten to twenty times faster than a direct pull: 0.21–0.26 s against 1.7–6.4 s across two runs on alpine:3.22 and nginx:1.29-alpine. That part is expected.
What is not expected is that a cold cache did not cost anything. The naive model says the cache is an extra hop, so the first pull should be slower. For the 26 MB nginx image it was 2.2–2.6 s through a cold zot against 5.1–6.4 s straight from Docker Hub, in both rounds. The reason is in the next section, and it is the part worth designing around.
A manifest request fills the whole image
Ask zot for a manifest and it syncs the image, without waiting for the client to request layer three. In the measurement, a client requested the manifest list and the child manifest and then went idle: redis:7-alpine grew the store by 12,659,243 bytes six seconds later, busybox:1.37 by 2,243,024 bytes eight seconds later, with zero blobs requested by the client.
So the cache warms itself ahead of the client, which explains the fast cold pulls — and it means disk usage tracks the number of distinct tags somebody asks for, not the number of containers running in production. A floating latest, or a per-commit tag in CI, adds a full image every rebuild, forever. Size the volume for tag churn you do not control, and alert on free disk rather than on cache size.
The upside is real: identical blobs are hardlinked across repositories, so pulling redis:7-alpine (16,420,206 bytes of layers) grew the store by only 12,659,243 — the Alpine base layer was already there. Two full runs over the same two images ended at 29,999,658 bytes, identical to the byte both times. A cache whose contents are deterministic is a cache you can reason about.
What I would do differently
Publish the port on 127.0.0.1, never 0.0.0.0. An unauthenticated zot answers /v2/ with 200, so a public port is a free bandwidth relay wearing your IP address; reach it over an SSH tunnel until you have TLS and auth in front.
Pick one client path and stick to it. A registry-prefixed reference (localhost:5000/library/nginx:1.29-alpine) tells you in the compose file whether a pull goes through the cache; a daemon-wide registry-mirrors entry is less typing and invisible in the reference the CLI prints, which is exactly what you do not want at 2 a.m.
And measure disk from inside a root container. zot does not run as root, so du -sb on the data directory as your own user fails with Permission denied on blobs/ and reports a cache of 170 KB. Idle cost of the finished cache: 65 MiB of RAM, 0.17% CPU.

