Skip to content

Upstream cache: bandwidth-limited and air-gapped deployments

Repod can act as a transparent pull-through cache in front of public upstream APT/RPM/APK repositories (Ubuntu, Debian, AlmaLinux, EPEL, Alpine…). Client machines point their sources.list/.repo files at Repod instead of the real upstream mirror; the first request for any given file is fetched and cached, every subsequent request is served locally. This guide covers:

  1. What the upstream cache is (and isn't)
  2. Adding a cache target
  3. Bandwidth-limited sites (cache-on-demand)
  4. Fully air-gapped sites (seeding via a relay host)
  5. Verifying the cache is actually working
  6. Retention, purge, and disk sizing

1. What the upstream cache is (and isn't)

The upstream cache is not the same thing as importing packages into your Repod repository (Import page / mirror scheduled sync). The difference matters:

Upstream cache Import / mirror
What's served Upstream files, byte-for-byte, upstream's own GPG signature Repod-validated packages, Repod's own GPG signature
Validation None — it's a relay, not a gate Full pipeline (ClamAV, Grype CVE, dependency check)
What a client sees The real upstream repo, just faster/locally cached A curated Repod distribution
Trigger Any request from any client (pull-through) Explicit import, or a scheduled sync of selected sources
Use case Bandwidth / availability / air-gap Security curation, CVE policy enforcement

Use the upstream cache when you want apt-get install anything-from-ubuntu to work without every machine reaching the internet. Use Import/mirror when you want packages validated and CVE-scanned before they land in your repo. The two are independent and can be used together.


2. Adding a cache target

In the UI: Cache upstream (left sidebar, under "Dépôt") → Ajouter une cible. Or via the API (admin token required):

curl -X POST https://repod.example.com/api/v1/upstream-cache/targets \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "id": "ubuntu-jammy",
    "label": "Ubuntu 22.04 (jammy)",
    "format": "apt",
    "base_url": "http://archive.ubuntu.com/ubuntu",
    "max_size_gb": 20,
    "inactive_days": 30
  }'

This creates a proxy at http://repod-host:8090/upstream/apt/ubuntu-jammy/ (port 8090 by default — see UPSTREAM_CACHE_PORT in .env — dedicated to the depot-cache service, separate from the regular :80 APT repo).

Point clients at it exactly as they would the real mirror:

# /etc/apt/sources.list.d/repod-cache.list
deb http://repod-host:8090/upstream/apt/ubuntu-jammy jammy main restricted universe multiverse
deb http://repod-host:8090/upstream/apt/ubuntu-jammy jammy-updates main restricted universe multiverse
deb http://repod-host:8090/upstream/apt/ubuntu-jammy jammy-security main restricted universe multiverse

For RPM (.repo file):

[repod-almalinux9-baseos]
name=AlmaLinux 9 BaseOS (cached via repod)
baseurl=http://repod-host:8090/upstream/rpm/almalinux9-baseos
gpgcheck=1
gpgkey=https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-9

gpgcheck/gpgkey still point at the upstream's own key — the cache doesn't re-sign anything, so package signature verification on the client works exactly as it would against the real mirror.


3. Bandwidth-limited sites (cache-on-demand)

No extra steps needed — this is the default mode. The first machine in your fleet to apt-get install/dnf install a given package pays the cost of fetching it from upstream; every other machine (and every repeat install, e.g. reimaging a machine) gets it from the local cache instantly. Only packages your fleet actually uses get cached — not the full upstream catalog — so disk usage stays proportional to your fleet's real footprint (see §6).


4. Fully air-gapped sites (seeding via a relay host)

If the Repod instance has no internet access at all, cache-on-demand can't self-seed — there's no upstream to reach on a cache miss. The supported workflow uses a relay host: any machine with internet access that can run the same depot-cache container.

Step 1 — stand up the cache on the relay host

On the internet-connected relay (doesn't need to be a full Repod install — just the cache piece):

git clone <your-repod-repo> && cd repod
docker build -f Dockerfile.cache-nginx -t repod-cache .
mkdir -p ./repos/upstream-cache/{conf/zones,conf/locations,data}
docker run -d --name depot-cache -p 8090:80 \
  -v "$(pwd)/repos/upstream-cache:/repos/upstream-cache" \
  -v "$(pwd)/nginx/upstream-cache.conf:/etc/nginx/conf.d/upstream-cache.conf:ro" \
  repod-cache

Then write the same target config the API would generate (or run a throwaway Repod backend against this relay's settings — simplest is to reuse the API if you have any Repod instance, even a temporary one, reachable from the relay):

curl -X POST http://localhost:8000/api/v1/upstream-cache/targets \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"id":"ubuntu-jammy","label":"Ubuntu 22.04","format":"apt",
       "base_url":"http://archive.ubuntu.com/ubuntu","max_size_gb":20,"inactive_days":30}'

Step 2 — warm the cache

Point real client tools at the relay's cache and run your normal update/ install workflow — this is the actual, real toolchain (apt-get/dnf), no custom package-list downloader needed:

# On a test machine pointed at the relay:
sudo sed -i 's|http://archive.ubuntu.com/ubuntu|http://relay-host:8090/upstream/apt/ubuntu-jammy|' /etc/apt/sources.list
sudo apt-get update
sudo apt-get install -y --download-only $(dpkg-query -W -f='${Package} ')   # or your known package list

Repeat for every distro/release/package set your air-gapped fleet actually needs. The cache now holds every file those commands touched.

Step 3 — export and transfer

bash scripts/cache-transfer.sh export ubuntu-jammy /media/usb/ubuntu-jammy.tar.gz

Move the USB drive (or any offline transfer medium your security policy allows) to the air-gapped site.

Step 4 — import on the air-gapped site

The air-gapped Repod instance must already have the same target configured (same id) — create it via the API/UI exactly as in §2 (this only writes config, no network call is made until a client actually requests a package):

bash scripts/cache-transfer.sh import ubuntu-jammy /media/usb/ubuntu-jammy.tar.gz

No restart is required — the imported files are picked up on the next request. Clients on the air-gapped network now get cache hits for everything you warmed on the relay, with zero internet access required.

Keeping it current

Repeat steps 2–4 periodically (e.g. monthly) on the relay to pick up security updates, then re-export/re-import only the delta — tar naturally only needs to move what changed if you keep the relay's cache directory persistent across warm-up runs (don't re-run docker run with a fresh empty volume each time).


5. Verifying the cache is actually working

Every response carries an X-Repod-Cache header:

curl -sI http://repod-host:8090/upstream/apt/ubuntu-jammy/dists/jammy/InRelease | grep X-Repod-Cache
  • MISS — fetched from upstream just now (first request for this path, or past its TTL).
  • HIT — served from local cache, no upstream contact.
  • STALE — upstream was unreachable, but a previously cached copy was still served (this is the resilience guarantee: a cached package is never blocked by an upstream outage).

To confirm resilience without waiting for a real outage, block the container's route to the upstream host (e.g. via iptables on the Docker host, or by temporarily editing /etc/hosts inside the container before restart) and re-request an already-cached path — you should still get 200 and X-Repod-Cache: HIT or STALE, never a hang or a 5xx for content that was already cached.


6. Retention, purge, and disk sizing

Each target has independent max_size_gb and inactive_days settings (editable from the Cache upstream page or PATCH /upstream-cache/targets/{id} — note: currently only enabled is patchable via the API; to change size/ retention, remove and re-add the target). Nginx evicts the least-recently-used entries once max_size_gb is reached, and drops anything untouched for inactive_days.

Sizing guidance:

  • Cache-on-demand, one distro release, a few hundred to ~2000 similar machines: typically 5–30 GB — proportional to your fleet's actual installed footprint, not the full upstream catalog.
  • A full upstream mirror (main+universe+multiverse, all pockets) would be hundreds of GB to a few TB — the upstream cache deliberately does not do this; use mirror_daily (Import page → scheduled sync) if you specifically need a complete, validated local mirror instead.

Purging (Cache upstream page → Purger, or POST /upstream-cache/targets/{id}/purge) clears the on-disk cache for one target without touching its configuration — the next request for any previously cached path becomes a fresh MISS. Use this after a bad upstream cache poisoning concern, or simply to reclaim disk space.