Architecture¶
Understanding how Repod is structured, and why it is designed this way, helps you reason about its security guarantees, its operational boundaries, and its failure modes. This page explains the design decisions behind the stack — not how to operate it, but why it works the way it does.
One backend, format-agnostic¶
Repod is a single FastAPI backend, a single PostgreSQL database, and a single React
frontend — there is no "APT edition" vs "RPM edition" split. The
REPO_FORMAT environment variable (read once at startup by
services/format_router.py) controls which package format(s) the backend manages:
REPO_FORMAT |
.deb (reprepro) |
.rpm (createrepo_c) |
.apk (apk index) |
|---|---|---|---|
apt (default) |
✅ | ||
rpm |
✅ | ||
apk |
✅ | ||
both |
✅ | ✅ | |
all |
✅ | ✅ | ✅ |
All validators, distribution managers, and importers are dispatched at runtime
through is_apt() / is_rpm() / is_apk() helpers in format_router.py. The
upload pipeline, RBAC model, audit log, SBOM export, and CVE workflow are
identical regardless of which formats are active.
Community vs Enterprise¶
Repod is distributed in two editions that share the same codebase and the same container topology — there is no separate "Enterprise build" to install.
Community Edition (AGPL-3.0) includes: - Full upload pipeline (antivirus scan, CVE analysis, GPG signing, dependency check) - Package management (upload, import from upstream, delete, distribution management) - JWT + API token authentication with 5-role RBAC - Audit logging (append-only JSONL) - LDAP / Active Directory authentication - Dashboard and health endpoints
Enterprise Edition adds, on top of Community:
- CVE review queue with CISO approval workflow (EPSS + CISA KEV enrichment)
- SBOM export in CycloneDX 1.5 and SPDX 2.3
- SSO/OIDC authentication, API tokens for CI/CD
- Configurable CVE policy per severity (block / review / warn / allow)
- SLA tracking and automated SLA alerts
- Automated daily security sync, scheduled backups with self-verification
- Historical multi-version snapshots
- Package retention policies, upstream cache for air-gapped sites
- Advanced settings API (webhook, SMTP notifications, LDAP, retention, CVE policy)
- Security report endpoint for NIS2 / ISO 27001 audit evidence
- Maven, PyPI, npm, and OCI container registry package formats
- CIS/STIG compliance profile scanning and configuration drift detection
- Per-distribution and per-machine access control, content filters
- Executive dashboard (fleet-wide CVE exposure trends)
- Inventory & SSH-based machine scanning, remote install jobs
- Multi-replica / active-passive high availability
Switching editions is a matter of activating a signed Enterprise license key — the API surface and container topology are identical either way.
Overview¶
Repod is built on three principles: security-first design, separation of concerns, and minimal privilege.
The separation-of-concerns principle is visible in the container split. The
repository servers (depot-apt, depot-rpm) are pure Nginx instances that serve
static files — they know nothing about users, authentication, or whether a package
is valid. The API backend (backend-api) handles all business logic: validation,
indexing, RBAC, audit logging, scheduling. The frontend (frontend-ui) is a
compiled React application delivered by its own Nginx process, with no server-side
code. PostgreSQL (db) holds all relational state. Each container has a single,
well-defined responsibility, and none can substitute for another.
The security-first principle means the threat model was taken seriously at design
time. The most visible consequence is the absence of the Docker socket. The
backend never mounts /var/run/docker.sock. Instead, it invokes reprepro/createrepo_c
directly against a shared volume — services/reprepro.py for APT (add_package()/
remove_package()), add-rpm.sh for RPM — and GPG operations use a
shared /repos/gnupg volume. The backend never talks to the Docker daemon.
A third structural decision is that the repository servers are intentionally dumb.
depot-apt serves dists/, pool/, and apk/; depot-rpm serves the
createrepo_c-managed trees. Neither has application logic, dynamic content, or
credentials. A client hitting these ports gets exactly the experience it would from
a public mirror — because that is what they are. The intelligence lives entirely in
the backend.
Component diagram¶
graph TD
Browser["Browser / curl"] -->|":3003 (HTTP)"| Frontend["frontend-ui\nNginx + React SPA"]
Browser -->|":80 (APT/APK)"| AptRepo["depot-apt\nNginx — static repo"]
Browser -->|":8080 (RPM)"| RpmRepo["depot-rpm\nNginx — static repo"]
Frontend -->|"REST API :8000"| Backend["backend-api\nFastAPI (Python)"]
AptCli["apt / apk"] -->|":80"| AptRepo
RpmCli["dnf / zypper"] -->|":8080"| RpmRepo
Backend -->|"clamscan subprocess"| ClamAV["ClamAV\n(in-container binary)"]
Backend -->|"grype subprocess"| Grype["Grype\n(in-container binary)"]
Backend -->|"gpg subprocess"| GnupgVol[("/repos/gnupg\nShared GPG keyring")]
Backend -->|"SQL (SQLAlchemy Core)"| PG[("PostgreSQL 16\nusers, manifests index,\ninventory, ssh_known_hosts")]
Backend -->|"JSON / JSONL"| ReposVol[("/repos/\nPackage artifacts, manifests,\naudit logs, security caches")]
AptRepo -->|"read-only"| PoolDists["pool/ + dists/ + apk/\n(served over HTTP)"]
RpmRepo -->|"read-only"| RpmTrees["rpm/<distro>/<arch>/\n(served over HTTP)"]
Backend -->|"read/write"| PoolDists
Backend -->|"read/write"| RpmTrees
Backend -->|"reprepro.add_package() / apk index"| AptRepo
Backend -->|"add-rpm.sh (createrepo_c)"| RpmTrees
GnupgVol -.->|"shared volume"| AptRepo
GnupgVol -.->|"shared volume"| RpmRepo
style ClamAV fill:#f9f,stroke:#333
style Grype fill:#f9f,stroke:#333
style GnupgVol fill:#ffe,stroke:#999
style PG fill:#ffe,stroke:#999
style ReposVol fill:#ffe,stroke:#999
ClamAV and Grype run as subprocess invocations inside the backend-api container —
they are not separate containers. This is a deliberate trade-off: it simplifies the
deployment (no inter-container networking for security tools) at the cost of
sharing the backend's CPU and memory budget. The resource limits in
docker-compose.yaml (2.5 GB RAM, 1.5 CPUs) reflect this — clamd alone needs
~800 MB to load its signature database.
Container breakdown¶
The default docker-compose.yaml ships with REPO_FORMAT=all and starts five
containers:
| Container | Image | Default port | Role | Key mounts |
|---|---|---|---|---|
repod-db |
postgres:16-alpine |
(internal only) | Application database | postgres_data volume |
depot-apt |
Custom (Nginx) | :80 |
Serves .deb (APT) and .apk (Alpine) repositories |
/repos/dists, /repos/pool, /repos/apk, /repos/gnupg, /repos/logs |
depot-rpm |
Custom (Nginx) | :8080 |
Serves .rpm repositories |
/repos/rpm, /repos/gnupg, /repos/logs |
backend-api |
Custom (Python 3.12 + FastAPI) | :8000 |
All business logic: upload pipeline, CVE review, RBAC, audit, scheduler | /repos/*, /repos/gnupg, /var/lib/clamav, /repos/grype-db, SSH key for inventory |
frontend-ui |
Custom (Node build + Nginx) | :3003 |
Serves the compiled React SPA, proxies /api/ to backend-api |
None (baked into image at build time) |
If REPO_FORMAT is set to apt, rpm, or apk, the unused repository container
(depot-rpm or the .apk-specific volumes on depot-apt) is simply unused — you
can remove it from docker-compose.yaml or leave it running idle.
The frontend is entirely stateless at runtime. Its configuration —
REACT_APP_API_URL and REACT_APP_REPO_URL — is baked in at Docker build time.
REACT_APP_API_URL must be empty so that all /api/v1/... calls stay
relative and are proxied by the frontend's Nginx to backend-api.
Data flow: upload path¶
This sequence describes what happens from the moment a user or CI/CD pipeline sends a package to the API until it is available to clients, regardless of format.
- The client sends a
POST /upload/(or/upload/streamfor SSE) multipart request with the package file and a target distribution (e.g.jammy,almalinux9,alpine3.20). A JWT or API token is required; the role must beuploader,maintainer, oradmin. - The backend writes the file to
/repos/staging/incoming/— a temporary holding area that is never served over HTTP. - The 6-step validation pipeline runs synchronously via
asyncio.to_thread()(see The security pipeline for detail). The pipeline reads the file from staging but never modifies it. - If validation fails (format error, SHA-256 mismatch, ClamAV virus, or a
blocking CVE policy): the file is moved to
/repos/staging/quarantine/, aFAILUREevent is written to the JSONL audit log, and the API returns422with the full step-by-step result. The package is unreachable by clients. - If validation passes but a CVE triggers a review policy: the file is moved
to
/repos/pool/, a manifest is generated at/repos/manifests/<name>_<version>_<arch>.manifest.jsonwithstatus: pending_review, and the package is added to the central index (/repos/manifests/index.json, also reflected in PostgreSQL) but not promoted into the repository tree. It is stored but not installable. - If validation passes cleanly: the backend calls the format-specific
repository tool —
- APT:
reprepro includedeb <distribution> <path>(viaservices/reprepro.py:add_package()), updatingdists/<distribution>/and re-signingInRelease - RPM:
createrepo_c --update <distrib>/<arch>/(viaadd-rpm.sh), regeneratingrepodata/and signingrepomd.xml - APK: rebuild
APKINDEX.tar.gzforapk/<distrib>/main/<arch>/and sign it
- APT:
The manifest is updated to status: indexed.
7. The audit log records an UPLOAD / SUCCESS event with the SHA-256 hash, the
uploader's username, and the full validation step results embedded in the entry.
Data flow: client install path¶
- The client reads
/etc/apt/sources.list.d/repod.list, pointing tohttp://<host>:80 <distribution> main. apt updatefetchesdists/<distribution>/InReleaseand verifies its GPG signature against the trusted key. If verification fails, APT refuses the repository.depot-apthas no role beyond serving the file.- APT parses
Packages.gz. Only packages withstatus: indexedexist in this tree;pending_revieworquarantinedpackages are invisible. apt install <package>downloads.debfiles frompool/and verifies their SHA-256 againstPackages.gz— standard APT behavior.
- The client reads
/etc/yum.repos.d/repod.repo, pointing tohttp://<host>:8080/repos/<distribution>/<arch>/. dnf/zypperfetchesrepodata/repomd.xmland verifies its GPG signature (gpgcheck=1).depot-rpmonly serves files.- The package list and dependency metadata come from
repodata/, generated bycreaterepo_c. Only indexed packages appear. dnf install <package>downloads.rpmfiles and verifies checksums fromrepodata/— standard DNF/Zypper behavior.
- The client has
http://<host>:80/apk/<distrib>/mainin/etc/apk/repositoriesand the signing key in/etc/apk/keys/. apk updatefetchesAPKINDEX.tar.gzand verifies its embedded signature against the trusted key.apk add <package>downloads.apkfiles and verifies checksums recorded inAPKINDEX— standard apk-tools behavior.
Storage layout¶
Relational data — users, the manifest index, inventory clients/packages, install
jobs, package-index full-text search, and ssh_known_hosts TOFU fingerprints —
lives entirely in PostgreSQL (DATABASE_URL, managed via SQLAlchemy Core +
Alembic). The filesystem under /repos/ holds everything that is not relational
data: package artifacts, manifests JSON, repository metadata trees, the GPG
keyring, caches, and logs.
/repos/
├── pool/ # Canonical package store — all uploads land here
│ └── main/<initial>/<package>/<name>_<version>_<arch>.{deb,rpm}
├── dists/ # APT index tree — managed by reprepro
│ └── <distribution>/main/binary-amd64/{Packages,Packages.gz}
├── rpm/ # RPM index trees — managed by createrepo_c
│ └── <distribution>/<arch>/repodata/
├── apk/ # Alpine index trees — managed by apk index
│ └── <distribution>/main/<arch>/APKINDEX.tar.gz
├── manifests/ # One JSON manifest per package version
│ ├── index.json # Aggregated index (atomic writes via os.replace)
│ └── <name>_<version>_<arch>.manifest.json
├── conf/ # reprepro config (distributions file)
├── db/ # reprepro internal database (APT repo metadata only)
├── audit/ # Append-only audit log, one file per day
│ └── YYYY-MM-DD.jsonl
├── gnupg/ # Shared GPG keyring (backend + depot-apt + depot-rpm)
├── staging/ # Transient area — never served over HTTP
│ ├── incoming/ # Files arrive here before validation
│ └── quarantine/ # Failed or rejected packages
├── imports/ # Packages fetched by sync/mirror jobs
├── security/ # CVE decisions and threat intelligence caches
│ ├── kev_cache.json # CISA KEV cache (TTL 24h)
│ └── epss_cache.json # EPSS scores cache (TTL 24h)
├── settings.json # Runtime config (scheduler, LDAP, sync, CVE policy)
├── grype-db/ # Grype vulnerability database cache
├── clamav-db/ # ClamAV signature database (daily.cld, main.cvd)
├── logs/ # Nginx access logs (shared with depot-* containers)
└── package-index/ # Full-text search working files
PostgreSQL data lives in the postgres_data Docker volume (see docker-compose.yaml),
not under /repos/. Both /repos/ (shared RWX across replicas) and the
PostgreSQL endpoint must be reachable from every backend replica in a
multi-replica deployment.
The separation between pool//rpm//apk/ (binaries + indexes) and manifests/
(metadata) is important. The manifest contains the entire validation history —
every pipeline step result, all CVE findings, dependency analysis, and the full
SHA-256/SHA-512 integrity record. The security posture of any package can be
reconstructed from the manifests alone, without re-running the scanner.
Security boundaries¶
What depot-apt / depot-rpm can access: Their respective repository trees
(read/write, managed by reprepro / createrepo_c / apk index) and the shared GPG
keyring (to sign index files). They cannot read manifests, audit logs, the
PostgreSQL database, staging files, or security decisions.
What backend-api can access: Everything under /repos/ and the PostgreSQL
database. It writes repository trees by invoking reprepro/createrepo_c/apk index
directly (APT via services/reprepro.py, RPM via add-rpm.sh, APK via
services/distributions_apk.py) against the shared volume — never through the
Docker daemon, which it cannot access.
What frontend-ui can access: Nothing on the filesystem at runtime. It is a
static file server. All data is fetched from the backend API by the user's browser.
Why Docker socket removal matters: If the backend mounted
/var/run/docker.sock, any path-traversal exploit, deserialization bug, or
dependency vulnerability in the FastAPI process could be escalated to full host
root access through the Docker API. The shared-volume approach limits the blast
radius: a compromised backend can modify files it has volume access to, but it
cannot launch containers, exec into other containers, or modify the host.
GPG via shared volume: /repos/gnupg is mounted into the backend and into
each repository container. The backend signs index files using
gpg --homedir /repos/gnupg, and the repository containers' tooling
(reprepro/createrepo_c/apk) uses the same keyring. The private key never leaves the
volume; there is no API call, no network hop.
Network¶
All containers share a single Docker bridge network (repod_network in the
bundled docker-compose.yaml). No container-to-container traffic is encrypted —
this is appropriate because they run on the same host and the network is not
exposed externally. The only external exposure is through published ports.
Default ports¶
| Port | Container | Exposed to | Purpose |
|---|---|---|---|
:80 |
depot-apt |
Configurable via BIND_HOST |
APT + APK repositories (plain HTTP, signed content) |
:8080 |
depot-rpm |
Configurable via BIND_HOST |
RPM repositories (plain HTTP, signed content) |
:8000 |
backend-api |
Configurable via BIND_HOST |
REST API (browser, CI/CD, curl) — never expose directly in production |
:3003 |
frontend-ui |
Configurable via BIND_HOST |
Web UI |
BIND_HOST defaults to 0.0.0.0, which binds to all interfaces. In production
behind a reverse proxy, set BIND_HOST=127.0.0.1 to prevent direct external access
to these ports and let the proxy handle TLS termination.
Repository protocols use plain HTTP on purpose. Content integrity is guaranteed by
GPG signature verification (InRelease, repomd.xml, APKINDEX), not by TLS. A
man-in-the-middle can observe what packages are being downloaded but cannot
substitute a malicious package without the private GPG key. TLS adds
confidentiality for the download list (useful in some threat models) but does not
strengthen the integrity guarantee. If TLS is required for compliance, terminate it
at the reverse proxy layer — see docker-compose.tls.yml.
Reverse proxy placement
When placing Repod behind Nginx or Caddy, configure TRUSTED_PROXIES in
backend.env to include the proxy's address range. The backend uses this list
to correctly extract client IPs from X-Forwarded-For headers for rate
limiting and audit logging. The default value covers 127.0.0.1 and private
RFC 1918 ranges.
High availability (active-passive)¶
For multi-replica deployments, backend/services/leader_election.py provides a
PostgreSQL-advisory-lock-based leader election so multiple backend-api replicas
can run against the same database and a shared /repos (NFS/EFS or equivalent).
On startup, each replica attempts a pg_try_advisory_lock(); the replica that
gets it becomes leader, the rest are passive. The lock is session-scoped, so if
the leader process dies, PostgreSQL releases it automatically and another
replica can acquire it on its next restart.
Only the leader runs the APScheduler cron jobs — scheduler_state.scheduler
stays unset on passive replicas. Endpoints that start an in-memory-tracked
background job (inventory scan, install job, mirror job, sync job) are gated
behind a require_leader dependency and return 503 on a passive replica,
because the job's progress, cancellation, and logs live only in the process
memory of whichever replica creates them — a client polling a different
replica would see nothing.
GET /health exposes checks.info.ha.is_leader/instance_id/
scheduler_active so a load balancer or operator can identify the current
leader. See docker-compose.ha.yml for a documented overlay example.
On any non-PostgreSQL database dialect (e.g. SQLite in tests) or any error
during election, leadership defaults to True — single-instance and test
deployments are unaffected by this mechanism.
High availability (active-active)¶
Active-passive leadership solves cron-job duplication, but it does not, by itself, make a replica's own background jobs visible to other replicas. Four job trackers (inventory scan, install, mirror, sync) plus the live dashboard event stream (Server-Sent Events) plus the live backend-log tail are, by default, per-process in-memory state — invisible to any replica other than the one that started them. That is the real reason job creation has to be leader-gated above: nothing about executing a scan or an install actually requires being the leader, only the fact that its state has nowhere else to live.
Setting JOB_STATE_BACKEND=redis (and pointing it at a reachable Redis
instance via JOB_STATE_REDIS_URL or REDIS_URL) moves that state into
Redis instead of process memory: progress, cancellation/confirmation signals,
concurrency slots, and logs are all written to Redis keys instead of local
variables, so any replica can read or update a job regardless of which
replica created it. Once a job flow's state is backend-verified as
distributed, the require_leader gate for creating that kind of job is
lifted — job creation, polling, cancellation, and confirmation all become
safe to route to any replica.
This is opt-in and off by default (JOB_STATE_BACKEND=local), which is
exactly today's in-memory behavior — a deployment that never sets this
variable sees no change at all. When enabled, it covers the complete
active-active surface, not a partial subset:
- The four job trackers — inventory scan, install, mirror, sync
- The dashboard SSE event bus (
GET /dashboard/events) - The live backend-log stream (
GET /logs/stream)
Fail-soft, never a silent HA regression: if JOB_STATE_BACKEND=redis is
set but Redis is unreachable, each component falls back to local-only
behavior independently, logs the fallback loudly, and keeps enforcing the
leader gate exactly as it would with no Redis configured at all — a passive
replica never starts silently accepting job creation just because the
intended distributed backend happened to be down at that moment. The SSE
event bus and the log stream have no leader gate to begin with (any replica
already accepts subscribers), so their fallback only means delivery becomes
single-replica again, not that anything starts rejecting requests.
GET /health exposes checks.info.ha.job_state_backend.{scan,install,mirror,sync,sse,logs},
each reporting "redis" or "local" — the actual backend a component is
using right now, after any fallback, not just the configured intent. This
lets an operator see exactly which flows are genuinely running active-active
at any given moment.
Active-active job state and active-passive leader election are complementary, not alternatives: leader election still decides who runs the scheduler and (for any flow not yet backend-verified as distributed) who may create a new job; the Redis-backed state layer decides whether that job's ongoing state is visible fleet-wide once it exists.
Multi-tenant SaaS mode¶
Repod can run in a multi-tenant mode (DEPLOYMENT_MODE=saas) where a single
deployment serves multiple independent organizations, each with its own
users, packages, distributions, and settings, with no visibility into any
other tenant's data. This mode is orthogonal to the high-availability
mechanisms above — either can be enabled independently, and both can be
combined in the same deployment. It is off by default: a standard on-premise
or Community Edition install runs in standalone mode, where none of this
applies.
Tenant resolution. A request's tenant is derived from the subdomain of
its Host header — acme.repod.io resolves to the tenant with slug acme.
This resolution happens once per request, in middleware, before any route
handler runs, and the resolved tenant is exposed to the rest of the request
through a context variable rather than being threaded through every function
signature — any service function that already reads the current tenant
context is tenant-aware for free, including code with no request in flight
at all (see cron jobs, below). A subdomain that doesn't resolve to an active
tenant is rejected before reaching application logic. Requests with no
subdomain (a bare IP or localhost) resolve to no tenant, which is what
standalone mode looks like from the same code path.
Database isolation: schema-per-tenant. Each tenant's relational data lives in its own PostgreSQL schema inside the same database, rather than in separate databases or in shared tables partitioned by a tenant column. Once a request's tenant is resolved, database connections for that request transparently target the tenant's own schema — application code that reads and writes through the normal database access layer needs no per-query tenant filtering, because the schema boundary does that job. A small set of genuinely cross-tenant data (the tenant registry itself, billing/subscription state) lives in a separate shared schema, outside any tenant's own schema.
Filesystem isolation follows the same pattern. Package artifacts,
manifests, audit logs, and runtime settings under /repos/ are resolved into
tenant-scoped subdirectories the same way the database schema is — keyed off
the same per-request tenant context, so a function that already resolves a
path like /repos/pool/ transparently resolves to that tenant's own
subdirectory instead once tenant context is active, with no separate
code path for the SaaS case.
Scheduled jobs fan out per tenant. APScheduler's cron jobs (retention cleanup, upstream security-index sync, mirror imports) read a single global schedule, but their actual work is tenant-scoped data — so in SaaS mode they loop over every active tenant and execute once per tenant, rather than once globally, using the same context-variable mechanism cron code uses to run under a specific tenant with no HTTP request involved. Work that is genuinely tenant-agnostic — a full-database backup, or downloading a public upstream package index whose content doesn't differ by tenant — runs once regardless of tenant count; only per-tenant policy application (which CVEs to flag, which packages to retain) is repeated per tenant.
A tenant whose subscription lapses is not deleted: its data remains isolated and intact, but the API is restricted to authentication, billing, and health endpoints until the subscription is resolved.