Skip to content

Distribution Management

A conceptual guide to how Repod models distributions, why the distinction matters, and how packages flow between them.


What is a distribution?

A distribution in Repod represents a target operating system release. When you upload or import a package, you assign it to a specific distribution. Only clients configured for that distribution will see the package.

The concept maps directly to the native package manager notion of a target:

An APT distribution is a codename (jammy, noble, bookworm). It appears in the sources.list entry:

deb http://repo.example.com/repos jammy main

Behind the scenes, reprepro manages a directory tree under /repos/dists/jammy/ containing InRelease, Release, and the Packages index, served by depot-apt.

An RPM distribution is a path component (almalinux9, fedora, rocky9). It appears in the .repo file:

baseurl=http://repo.example.com:8080/repos/almalinux9/x86_64/

Behind the scenes, createrepo_c manages a directory tree under /repos/rpm/almalinux9/x86_64/ containing repodata/repomd.xml and the RPM files, served by depot-rpm.

An Alpine distribution is also a path component (alpine3.20). It appears in /etc/apk/repositories:

http://repo.example.com/apk/alpine3.20/main

Behind the scenes, apk index manages a directory tree under /repos/apk/alpine3.20/main/x86_64/ containing APKINDEX.tar.gz and the .apk files, served by depot-apt under /apk/.


Why distributions matter

Binary compatibility

A .deb built for Ubuntu 22.04 (jammy) may not install correctly on Ubuntu 24.04 (noble) — different glibc version, different default Python, different SSL library ABI. The same applies across RPM-based distributions (AlmaLinux 8 vs. 9) and Alpine releases (musl ABI changes between alpine3.18 and alpine3.21). Keeping distributions separate prevents mismatched packages from reaching the wrong OS.

CVE accuracy

Grype uses the distribution ID (almalinux:9, rockylinux:9, opensuse/leap:15.6, etc., configured per-codename in services/distributions_rpm.py) to filter CVE advisories. A vulnerability may be patched in AlmaLinux 9 but not in AlmaLinux 8 — without the correct distribution context, Grype would return false positives or miss relevant advisories.

For Ubuntu/Debian, Grype correlates against Ubuntu Security Notices (USN) and Debian Security Tracker entries per-release, which similarly requires the codename to be known.

Rollout control

Distributions serve as staging tiers. A common pattern:

Development → QA → Production
(focal)        (jammy)   (noble)

Package promotion moves a binary between distributions without re-uploading or re-scanning (the CVE decision is preserved). See Promoting packages.


Supported distributions

Which of these are active depends on REPO_FORMAT (apt, rpm, apk, both, or all) — see Getting Started — Step 2.

Codename OS Architecture
jammy Ubuntu 22.04 LTS amd64
noble Ubuntu 24.04 LTS amd64
focal Ubuntu 20.04 LTS amd64
bookworm Debian 12 amd64
Codename OS Architecture Grype distro ID
almalinux8 AlmaLinux 8 x86_64 almalinux:8
almalinux9 AlmaLinux 9 x86_64 almalinux:9
rocky8 Rocky Linux 8 x86_64 rockylinux:8
rocky9 Rocky Linux 9 x86_64 rockylinux:9
centos-stream9 CentOS Stream 9 x86_64 centos:9
oraclelinux8 Oracle Linux 8 x86_64 oraclelinux:8
fedora Fedora 42 x86_64 fedora:42
opensuse-leap-15.6 openSUSE Leap 15.6 x86_64 opensuse/leap:15.6
opensuse-tumbleweed openSUSE Tumbleweed x86_64 opensuse/tumbleweed:latest
Codename OS Architecture
alpine3.18 Alpine Linux 3.18 x86_64
alpine3.19 Alpine Linux 3.19 x86_64
alpine3.20 Alpine Linux 3.20 x86_64
alpine3.21 Alpine Linux 3.21 x86_64

Repository filesystem layout

Understanding the on-disk layout helps when troubleshooting or integrating with other tools. All three trees live side by side under /repos/ in the same backend container — which ones exist depends on REPO_FORMAT.

/repos/
├── conf/
│   └── distributions           ← reprepro configuration
├── db/                         ← reprepro internal database
├── dists/
│   ├── jammy/
│   │   ├── InRelease           ← GPG-signed index
│   │   ├── Release
│   │   ├── Release.gpg
│   │   └── main/
│   │       ├── binary-amd64/
│   │       │   ├── Packages
│   │       │   ├── Packages.gz
│   │       │   └── Packages.xz
│   │       └── Contents-amd64.gz
│   ├── noble/
│   ├── focal/
│   └── bookworm/
└── pool/
    └── main/
        └── n/nginx/
            └── nginx_1.24.0-1_amd64.deb

The pool/ directory is shared across distributions. A package binary is stored once; reprepro's includedeb command creates the index entry that makes it visible in a specific distribution.

/repos/rpm/
├── almalinux9/
│   └── x86_64/
│       ├── repodata/
│       │   ├── repomd.xml          ← main index
│       │   ├── repomd.xml.asc      ← GPG detached signature
│       │   ├── primary.xml.gz      ← package metadata
│       │   ├── filelists.xml.gz
│       │   └── other.xml.gz
│       └── nginx-1.24.0-1.el9.ngx.x86_64.rpm
├── rocky9/
│   └── x86_64/
└── fedora/
    └── x86_64/

Each RPM distribution has its own complete directory. Unlike APT, RPM packages are not deduplicated across distributions — each distribution holds its own copy of the binary.

/repos/apk/
├── alpine3.20/
│   └── main/
│       └── x86_64/
│           ├── APKINDEX.tar.gz     ← signed package index
│           └── mypackage-1.0.0-r0.apk
└── alpine3.21/
    └── main/
        └── x86_64/

Like RPM, each Alpine distribution holds its own copy of every package binary — there is no shared pool.


Promoting packages

Promotion moves a package from one distribution to another without re-uploading or re-scanning. The CVE decision record and justification are preserved.

# Promote nginx from jammy (dev/QA) to noble (production)
curl -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  http://localhost:8000/api/v1/distributions/promote \
  -d '{"package":"nginx","from_dist":"jammy","to_dist":"noble"}'

What happens internally:

  1. Repod copies the .deb binary in pool/ (or creates a hard link).
  2. reprepro includedeb <to_dist> <path> adds the package to the target distribution's index.
  3. reprepro re-signs the InRelease file for the target distribution.
  1. Repod copies the .rpm file to the target distribution directory.
  2. createrepo_c --update re-generates the repodata/ index.
  3. The repomd.xml.asc GPG signature is regenerated.
  1. Repod copies the .apk file to the target distribution directory.
  2. apk index regenerates and re-signs APKINDEX.tar.gz.

Role required

Promotion requires the maintainer or admin role.


Migrating packages between distributions

Migration copies all packages from one distribution to another. Useful when upgrading your OS baseline across your infrastructure.

# Migrate all packages from focal to jammy
curl -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  http://localhost:8000/api/v1/distributions/migrate \
  -d '{"from_dist":"focal","to_dist":"jammy"}'

Source distribution is not removed

After migration, packages exist in both distributions. Remove packages from the source distribution manually if needed.


Content filters (allow/deny lists)

Content filters restrict which packages can enter a distribution — a lightweight equivalent to Katello Content View filters, scoped to package names (exact match, glob wildcard, or regex). A distribution with no filters behaves exactly as before (open to any package) — filters are strictly opt-in, no existing deployment changes behavior by upgrading.

When a filter applies

A filter is evaluated before any file touches disk — at upload, at internet import, and at promotion/migration into the target distribution. A rejected package is never written to pool/, never indexed, and never reaches the distribution's Packages/repomd.xml/APKINDEX tree. This is different from the CVE review workflow (pending_review): a filter is a binary policy gate, not a queue for human approval.

Repod doesn't have a Katello-style unfiltered "Library" sitting behind a published "Content View" — upload/import already validates and publishes in one step. Filters therefore attach directly to the distribution they protect, not to a separate composable view object.

Evaluation order

If a distribution has any allow rules, a package must match at least one of them, or it's rejected. Then, regardless of the allow outcome, any matching deny rule winsdeny always has the final word.

no rules             → allowed
allow rules exist,
  no match            → rejected
allow matches,
  deny also matches   → rejected (deny wins)
allow matches,
  no deny match        → allowed
no allow rules,
  deny matches          → rejected
no allow rules,
  no deny match         → allowed

Managing filters

# Block a specific package by exact name
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  http://localhost:8000/api/v1/distributions/jammy/filters \
  -d '{"rule_type":"deny","match_type":"exact","pattern":"telnet","description":"Insecure protocol, banned by policy"}'

# Block every -dev package family with a glob
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  http://localhost:8000/api/v1/distributions/jammy/filters \
  -d '{"rule_type":"deny","match_type":"glob","pattern":"*-dbg"}'

# Restrict a production distribution to an explicit allowlist (regex)
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  http://localhost:8000/api/v1/distributions/noble/filters \
  -d '{"rule_type":"allow","match_type":"regex","pattern":"^(nginx|openssl|curl|ca-certificates)$"}'

# List current rules
curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/v1/distributions/jammy/filters

# Remove a rule
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
  http://localhost:8000/api/v1/distributions/jammy/filters/{rule_id}

Role required

Adding, removing, or sweeping filters requires the admin role — reading the current rules only requires distribution access (same RBAC as GET /distributions/{codename}/packages).

Previewing before you commit

Test what a rule would do — against an existing package name or a hypothetical one — without saving anything:

curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  http://localhost:8000/api/v1/distributions/jammy/filters/preview \
  -d '{"name":"telnet"}'
# → {"allowed": false, "reason": "'telnet' blocked by rule deny/exact 'telnet'", "matched_rule": {...}}

This same endpoint is what the UI's "Test a package" box in the Distributions → (select a distribution) → Filters tab calls, so you can validate a rule's effect before adding it, or understand why a real upload was rejected.

Filters are never retroactive by themselves

Adding a rule to a distribution that already has matching packages does not remove anything automatically — a filter is forward-looking policy, not a silent purge. To reconcile existing content against the current rules, run an explicit sweep:

# Dry run (default) — lists what would be removed, changes nothing
curl -X POST -H "Authorization: Bearer $TOKEN" \
  "http://localhost:8000/api/v1/distributions/jammy/filters/sweep"

# Apply — actually removes non-compliant packages from THIS distribution only
curl -X POST -H "Authorization: Bearer $TOKEN" \
  "http://localhost:8000/api/v1/distributions/jammy/filters/sweep?dry_run=false"

The apply step only ever touches the one distribution being swept — a package removed from jammy because of jammy's filter is untouched in noble or any other distribution.


Initializing distributions

Distributions are initialized automatically on first startup for every format enabled by REPO_FORMAT. If you need to re-initialize (e.g. after restoring a backup without the distribution trees):

curl -X POST -H "Authorization: Bearer $TOKEN" \
  http://localhost:8000/api/v1/distributions/init

Adding unsupported distributions

The distribution lists are defined in the backend source code:

  • backend/services/distributions_apt.py — APT codenames
  • backend/services/distributions_rpm.py — RPM codenames + Grype distro IDs
  • backend/services/distributions_apk.py — APK codenames

Adding a new distribution requires:

  1. Editing the relevant distributions_*.py file to add the new codename / metadata.
  2. For APT, also adding the codename to conf/distributions.
  3. Rebuilding the backend image: docker compose build backend-api.
  4. Calling POST /api/v1/distributions/init to create the on-disk structure.

This is intentionally gated behind a code change to prevent accidental distribution sprawl in production.


Architecture support

The current release supports amd64 (APT) / x86_64 (RPM, APK) only. ARM (arm64 / aarch64) support is planned for a future release — the upload endpoint currently rejects packages with unsupported architectures.