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:
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:
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:
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:
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.
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:
- Repod copies the
.debbinary inpool/(or creates a hard link). reprepro includedeb <to_dist> <path>adds the package to the target distribution's index.- reprepro re-signs the
InReleasefile for the target distribution.
- Repod copies the
.rpmfile to the target distribution directory. createrepo_c --updatere-generates therepodata/index.- The
repomd.xml.ascGPG signature is regenerated.
- Repod copies the
.apkfile to the target distribution directory. apk indexregenerates and re-signsAPKINDEX.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 wins — deny 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):
Adding unsupported distributions¶
The distribution lists are defined in the backend source code:
backend/services/distributions_apt.py— APT codenamesbackend/services/distributions_rpm.py— RPM codenames + Grype distro IDsbackend/services/distributions_apk.py— APK codenames
Adding a new distribution requires:
- Editing the relevant
distributions_*.pyfile to add the new codename / metadata. - For APT, also adding the codename to
conf/distributions. - Rebuilding the backend image:
docker compose build backend-api. - Calling
POST /api/v1/distributions/initto 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.