Files
homelab-optimized/docs/services/individual/zot.md
Gitea Mirror Bot 9fa5b7654e
Some checks failed
Documentation / Deploy to GitHub Pages (push) Has been cancelled
Documentation / Build Docusaurus (push) Has been cancelled
Sanitized mirror from private repository - 2026-04-16 07:18:01 UTC
2026-04-16 07:18:01 +00:00

5.7 KiB

Zot — OCI Pull-Through Registry Cache

Overview

Zot is a single-container OCI registry running on Atlantis that acts as a pull-through cache for Docker Hub (docker.io).

Why: Docker Hub rate-limits unauthenticated pulls (100/6h per IP). After the first pull, any Docker Hub image is served instantly from local cache — no upstream latency on deploys, watchtower updates, or container restarts.

Host Atlantis
Port 5050 (5000 was taken by nginx)
Web UI http://100.83.230.112:5050
Metrics http://100.83.230.112:5050/metrics
Compose hosts/synology/atlantis/zot.yaml
Config hosts/synology/atlantis/zot/config.json
Data /volume2/metadata/docker2/zot/data/ on Atlantis

Scope — What Zot Caches

Zot caches Docker Hub images only.

Docker's registry-mirrors mechanism — the standard way to redirect pulls through a local cache — only intercepts pulls from the default registry (Docker Hub). When a compose file explicitly names a registry (e.g. lscr.io/linuxserver/sonarr, ghcr.io/immich-app/immich-server), Docker contacts that registry directly and bypasses the mirror entirely.

Image type Example Goes through Zot?
Unqualified Docker Hub postgres:16, nginx:alpine, redis:7 Yes
Explicit Docker Hub docker.io/library/postgres:16 Yes
LinuxServer lscr.io/linuxserver/sonarr:latest No — direct
GitHub packages ghcr.io/immich-app/immich-server:release No — direct
Quay quay.io/prometheus/node-exporter:latest No — direct

What this means in practice: Official images (postgres, redis, nginx, alpine, mariadb, mosquitto, etc.) are cached. LinuxServer, Immich, Authentik, Tdarr, and all other explicitly-prefixed images are not.

Expanding scope (future option)

To cache lscr.io/ghcr.io/quay.io images, all compose files referencing those images would need to be rewritten to pull from Zot directly (e.g. 100.83.230.112:5050/linuxserver/sonarr:latest), and Zot's sync config updated to poll those registries. This is ~60 compose file changes across all hosts — deferred for now.


How It Works

docker pull postgres:16
       │
       ▼
Docker daemon checks registry-mirrors first
       │
       ▼
Zot (100.83.230.112:5050)
  ├── cached? → serve instantly from local disk
  └── not cached? → fetch from registry-1.docker.io, cache, serve

docker pull lscr.io/linuxserver/sonarr:latest
       │
       ▼
Docker sees explicit registry prefix → bypasses mirror
       │
       ▼
lscr.io (direct, not cached)

All registries are configured with onDemand: true — nothing is pre-downloaded. Images are only cached when first requested, then served locally forever after (until GC removes unreferenced blobs after 24h).

Docker Hub note: Docker Hub does not support catalog listing, so poll mode cannot be used with it. On-demand only is correct.


Storage

Images are stored deduplicated at /volume2/metadata/docker2/zot/data/. GC runs every 24h to remove blobs no longer referenced by any cached manifest.


Per-Host Mirror Configuration

Each Docker host has been configured with Zot as a registry mirror. This is a Docker daemon setting — done once per host, not managed by Portainer.

Status

Host Configured Method
Atlantis Done (manual) DSM Container Manager → Registry → Settings → Mirror → http://localhost:5050
Calypso Done (manual) DSM Container Manager → Registry → Settings → Mirror → http://100.83.230.112:5050
homelab-vm Done /etc/docker/daemon.json
NUC Done /etc/docker/daemon.json
Pi-5 Done /etc/docker/daemon.json

daemon.json format (Linux hosts)

{
  "registry-mirrors": ["http://100.83.230.112:5050"],
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "3" }
}

After editing, restart Docker: sudo systemctl restart docker


Adding Credentials (optional)

Without credentials, public Docker Hub images pull fine but rate-limits apply (100 pulls/6h per IP). With a Docker Hub account: 200/hr per account.

To add credentials, create this file directly on Atlantis (never in git):

cat > /volume2/metadata/docker2/zot/credentials.json << 'EOF'
{
  "registry-1.docker.io": {
    "username": "your-dockerhub-username",
    "password": "your-dockerhub-token"  // pragma: allowlist secret
  }
}
EOF

Then uncomment the credentials volume mount in zot.yaml and add "credentialsFile": "/etc/zot/credentials.json" to the sync block in config.json. Restart the stack via Portainer.


Verifying It Works

# Zot health check
curl http://100.83.230.112:5050/v2/
# Returns empty body with HTTP 200

# View cached images
curl http://100.83.230.112:5050/v2/_catalog

# Pull a Docker Hub image on any configured host, then check catalog
docker pull alpine:latest
curl http://100.83.230.112:5050/v2/_catalog
# "library/alpine" should appear

Known Limitations

  • lscr.io / ghcr.io / quay.io images bypass the cache — Docker's mirror mechanism only intercepts unqualified (Docker Hub) pulls. See Scope section above.
  • Port 5050 — port 5000 was already in use by nginx on Atlantis.
  • No TLS — Zot is internal-only (LAN + Tailscale). Not exposed via NPM.
  • No authentication — read-only, internal network access only.

Prometheus Integration

Zot exposes metrics at /metrics. Add to Prometheus scrape config:

- job_name: 'zot'
  static_configs:
    - targets: ['100.83.230.112:5050']
  metrics_path: '/metrics'