Documentation

Cache images for CI

CI jobs start from clean runners, so every build and test job pulls the same base images again. Docker Hub caps anonymous pulls at 100 per six hours per IP, and a busy fleet behind one NAT egress burns through that in minutes. Run one peryx where your runners live, point their container clients at it, and the first job warms the cache while the rest pull layers from local disk.

Run peryx next to the runners

On the CI host, or as a service in the runner network:

peryx serve --host 0.0.0.0 --port 4433 --data-dir /var/lib/peryx

Configure a proxy of Docker Hub. The route becomes the name prefix clients pull through:

# peryx.toml
[[index]]
name = "dockerhub"
route = "dockerhub"
ecosystem = "oci"
cached = "https://registry-1.docker.io"

The data directory is the cache; give it a persistent volume. Nothing else is stateful (no database, no sidecar), so in Kubernetes or docker-compose this is one container with one volume.

Transport: HTTP on loopback, TLS over the network

docker and podman trust a loopback registry (localhost, 127.0.0.0/8) over plain HTTP with no configuration, so a runner on the same host as peryx works as written. Reaching peryx across the runner network (the usual CI shape) means a client demands HTTPS: give peryx a certificate (serve HTTPS, the production path) or set the client's insecure-registry option. crane and podman take a per-command flag; docker needs an insecure-registries entry in its daemon config. The rest of this guide assumes peryx answers at peryx.internal:4433.

Pull through the proxy

Point the client at the route instead of Docker Hub. alpine:latest becomes peryx.internal:4433/dockerhub/library/alpine:latest:

docker pull peryx.internal:4433/dockerhub/library/alpine:latest
podman pull peryx.internal:4433/dockerhub/library/alpine:latest
crane pull peryx.internal:4433/dockerhub/library/alpine:latest alpine.tar

The first pull runs the upstream's bearer-token handshake, verifies each digest, and caches every blob; later pulls come from disk. Because blobs are content-addressed, a layer shared across images crosses your uplink once, ever.

Rewrite images in a pipeline

Most pipelines name images inline, so the direct form is to prefix the route in the pull step. A GitHub Actions job:

jobs:
  test:
    runs-on: [self-hosted]
    steps:
      - run: docker pull peryx.internal:4433/dockerhub/library/postgres:16
      - run: docker run --rm peryx.internal:4433/dockerhub/library/postgres:16

Or mirror Docker Hub transparently

To leave every docker pull alpine unchanged, register peryx as a Docker Hub mirror in the daemon config. The daemon then routes Docker Hub pulls through peryx without any prefix in the image name:

{
  "registry-mirrors": [
    "https://peryx.internal:4433"
  ]
}

Reload the daemon (systemctl reload docker) and bake this daemon.json into your runner image. Note the https://: the mirror endpoint must be TLS, so peryx needs a trusted certificate. If it serves plain HTTP, add its host to insecure-registries in the same file:

{
  "registry-mirrors": [
    "http://peryx.internal:4433"
  ],
  "insecure-registries": [
    "peryx.internal:4433"
  ]
}

registry-mirrors covers Docker Hub only; images from GHCR, ECR, or a private registry still resolve directly. For those, front each with its own proxy index (point cached at https://ghcr.io and pull through that route) and rewrite the image reference.

Verify it is working

Watch a couple of jobs, then check what the cache absorbed:

curl -s 'http://peryx.internal:4433/+stats?index=dockerhub' | jq .totals

downloads and bytes count what peryx served; once the working set is warm, upstream traffic drops to manifest revalidations while layer bytes come from disk. /metrics feeds the same numbers to Prometheus.

Why this works as well as it does

  • Blobs are immutable and content-addressed: each layer crosses your uplink once, and deduplicates across every image and tag that shares it.
  • Concurrent pulls of one uncached layer collapse to a single upstream fetch, so a fan-out of parallel jobs does not multiply the miss.
  • The anonymous pull-rate limit stops being the wall: after warm-up, peryx serves the fleet and Docker Hub sees revalidations, not a hundred cold pulls an hour.
  • How peryx compares to distribution and zot as a Docker Hub cache: OCI performance.
  • The full role walkthrough, hosted and virtual as well as proxy: run a container registry.
On this page