Skip to content

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.

  1. The client sends a POST /upload/ (or /upload/stream for 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 be uploader, maintainer, or admin.
  2. The backend writes the file to /repos/staging/incoming/ — a temporary holding area that is never served over HTTP.
  3. 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.
  4. If validation fails (format error, SHA-256 mismatch, ClamAV virus, or a blocking CVE policy): the file is moved to /repos/staging/quarantine/, a FAILURE event is written to the JSONL audit log, and the API returns 422 with the full step-by-step result. The package is unreachable by clients.
  5. 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.json with status: 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.
  6. If validation passes cleanly: the backend calls the format-specific repository tool —
    • APT: reprepro includedeb <distribution> <path> (via services/reprepro.py:add_package()), updating dists/<distribution>/ and re-signing InRelease
    • RPM: createrepo_c --update <distrib>/<arch>/ (via add-rpm.sh), regenerating repodata/ and signing repomd.xml
    • APK: rebuild APKINDEX.tar.gz for apk/<distrib>/main/<arch>/ and sign it

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

  1. The client reads /etc/apt/sources.list.d/repod.list, pointing to http://<host>:80 <distribution> main.
  2. apt update fetches dists/<distribution>/InRelease and verifies its GPG signature against the trusted key. If verification fails, APT refuses the repository. depot-apt has no role beyond serving the file.
  3. APT parses Packages.gz. Only packages with status: indexed exist in this tree; pending_review or quarantined packages are invisible.
  4. apt install <package> downloads .deb files from pool/ and verifies their SHA-256 against Packages.gz — standard APT behavior.
  1. The client reads /etc/yum.repos.d/repod.repo, pointing to http://<host>:8080/repos/<distribution>/<arch>/.
  2. dnf/zypper fetches repodata/repomd.xml and verifies its GPG signature (gpgcheck=1). depot-rpm only serves files.
  3. The package list and dependency metadata come from repodata/, generated by createrepo_c. Only indexed packages appear.
  4. dnf install <package> downloads .rpm files and verifies checksums from repodata/ — standard DNF/Zypper behavior.
  1. The client has http://<host>:80/apk/<distrib>/main in /etc/apk/repositories and the signing key in /etc/apk/keys/.
  2. apk update fetches APKINDEX.tar.gz and verifies its embedded signature against the trusted key.
  3. apk add <package> downloads .apk files and verifies checksums recorded in APKINDEX — 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.