Production Deployment¶
Step-by-step guide for deploying Repod on a production Linux server.
System requirements¶
Operating system¶
- Debian 11/12 or Ubuntu 22.04/24.04 LTS (host OS — Repod itself runs in containers and can serve any combination of APT/RPM/APK)
- Root or
sudoaccess
Required software¶
| Software | Minimum version | Check command |
|---|---|---|
| Docker Engine | 24.0 | docker --version |
| Docker Compose | 2.20 (plugin) | docker compose version |
| Git | 2.x | git --version |
| OpenSSL | 1.1+ | openssl version |
Use the v2 Compose plugin
Use docker compose (v2 plugin), not the legacy docker-compose command.
Install Docker (if not present)¶
sudo apt-get remove -y docker docker-engine docker.io containerd runc
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg lsb-release
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER
newgrp docker
Hardware requirements¶
| Resource | Minimum | Recommended |
|---|---|---|
| CPU | 2 vCPU | 4 vCPU |
| RAM | 3 GB | 6 GB+ |
| Disk | 20 GB | 100 GB+ |
RAM accounts for PostgreSQL, ClamAV (clamd loads ~800 MB of signatures into the
backend container), and Grype. Disk space must accommodate all package binaries
under repos/pool/ and repos/rpm/ plus the postgres_data Docker volume.
Provision according to your expected package volume.
Internet access¶
Repod runs entirely offline by default — nothing in the core upload → scan → publish → serve pipeline requires outbound connectivity. A handful of optional features (ClamAV/Grype database updates, CVE enrichment, importing packages from public sources) do reach out, and if the server sits behind restricted egress, see Proxy configuration → for the full list of outbound dependencies and a worked example routing them through a Squid allowlist.
Network ports¶
The bundled docker-compose.yaml runs REPO_FORMAT=all by default — both APT/APK
and RPM repository servers are started.
| Port | Service | Exposure |
|---|---|---|
80 |
depot-apt — APT (.deb) and Alpine (.apk) repositories |
LAN or public (apt/apk clients) |
8080 |
depot-rpm — RPM (.rpm) repositories |
LAN or public (dnf/zypper clients) |
3003 |
Web interface (frontend-ui) |
Internal or VPN |
8000 |
Backend API (backend-api) |
Reverse proxy only — never expose directly |
| — | PostgreSQL (repod-db) |
Internal Docker network only, not published |
If you only need one package format, set REPO_FORMAT accordingly (apt, rpm,
or apk) and remove the unused repository service from docker-compose.yaml — see
Getting Started — Step 2.
Step 1 — Clone the repository¶
sudo mkdir -p /opt/repod
sudo chown $USER:$USER /opt/repod
cd /opt/repod
git clone https://github.com/getautoflow/repod .
Verify the project structure:
ls /opt/repod
# Expected: backend/ frontend/ repos/ docker-compose.yaml .env.example backend.env.example
Step 2 — Configure environment variables¶
.env (ports, database password, and build-time frontend URLs)¶
Typical content — replace repo.example.com with your actual domain or IP:
# Bind to loopback when a reverse proxy handles external traffic
BIND_HOST=127.0.0.1
# Public URLs embedded into the frontend bundle at build time
# REACT_APP_API_URL must stay empty (relative /api/v1/... calls)
REACT_APP_API_URL=
REACT_APP_REPO_URL=https://repo.example.com
REACT_APP_RPM_REPO_URL=https://repo.example.com:8080
# Port mapping
BACKEND_PORT=8000
FRONTEND_PORT=3003
APT_PORT=80
RPM_REPO_PORT=8080
# PostgreSQL — must match DATABASE_URL in backend.env
POSTGRES_PASSWORD=<output of openssl rand -hex 24>
backend.env (secrets, database connection, and runtime config)¶
Generate the required secrets (one openssl rand -hex 32 per secret):
# ── Database ────────────────────────────────────────────────────────────────
# Informational only — the actual connection string is built by
# docker-compose.yaml from POSTGRES_PASSWORD in .env, always in sync with
# the database's real password. Don't set a different value here.
DATABASE_URL=postgresql://repod:<same-password-as-.env>@db:5432/repod
# ── Repository format ──────────────────────────────────────────────────────
# Informational only — REPO_FORMAT is hardcoded in docker-compose.yaml
# (service "backend", environment: block), not read from either env file.
# To change it, edit that line directly and remove the now-unused
# repository service (apt-repo/rpm-repo).
REPO_FORMAT=all
# ── Security ────────────────────────────────────────────────────────────────
JWT_SECRET_KEY=<output of openssl rand -hex 32>
JWT_EXPIRE_MINUTES=60
SETTINGS_ENCRYPTION_KEY=<output of openssl rand -hex 32>
WEBHOOK_SECRET=<output of openssl rand -hex 32>
CORS_ORIGINS=https://repo.example.com
# ── Environment ─────────────────────────────────────────────────────────────
ENV=production
APP_VERSION=v1.2.0
Secure the files:
No admin account is created at this stage. After the stack is up, create the first admin via the setup wizard:
curl -X POST http://localhost:8000/api/v1/setup/ \
-H "Content-Type: application/json" \
-d '{"admin_username":"admin","admin_password":"YourPassword!"}'
Pre-provisioning an admin (optional, for automated deployments)
Set both ADMIN_USERNAME and ADMIN_PASSWORD_HASH (bcrypt, $ doubled
to $$) in backend.env before first startup:
Protecting the setup wizard (optional)
Set SETUP_TOKEN=<output of openssl rand -hex 32> in backend.env to
require an X-Setup-Token header on POST /api/v1/setup until the first
admin is created.
Optional — using an external (non-containerized) PostgreSQL database¶
DATABASE_URL is a plain PostgreSQL connection string — nothing in the
backend requires the database to run inside a Docker container. Pointing it
at an external PostgreSQL 16+ instance (a dedicated VM, an existing HA
cluster, a managed database service) is fully supported and is the
recommended setup for an on-premise deployment that already has its own
PostgreSQL operational practice — dedicated backup/PITR tooling, monitoring,
memory/disk tuning, and failover, all managed outside Docker rather than
tied to a container volume. The bundled db service (postgres:16-alpine
in docker-compose.yaml) exists purely as a convenient zero-admin default
for single-node installs; it is not a hard dependency.
Editing backend.env alone is not enough. In the bundled
docker-compose.yaml, the backend service's environment: block hardcodes
DATABASE_URL, derived from POSTGRES_PASSWORD in .env:
services:
backend:
environment:
DATABASE_URL: postgresql://repod:${POSTGRES_PASSWORD:-repod_dev_password}@db:5432/repod
A Compose service's environment: entry always takes precedence over the
same variable set via env_file: (backend.env) — so a DATABASE_URL
edited only in backend.env is silently overridden and the backend still
connects to the bundled db container. To use an external database:
- Provision the database — PostgreSQL 16 or later, a
repoddatabase and a role with full privileges on it, reachable from thebackend-apicontainer over the network (same host, private network, or VPN — never expose PostgreSQL directly to the internet). - Edit
docker-compose.yaml: replace the hardcodedDATABASE_URLline in thebackendservice with your external connection string (or remove the line entirely and rely onbackend.env'sDATABASE_URLinstead, since nothing then overrides it), and delete thedb:service block — it would otherwise start and sit unused. - Remove the
dbservice's dependency — thebackendservice'sdepends_on: [db](and itscondition: service_healthy, if present) must be removed along with thedb:block, or Compose will refuse to start. - Continue with Step 3 below as normal — schema creation (Alembic
migrations, run automatically by
entrypoint.shon first startup) works identically against any PostgreSQL 16+ instance.
This is the same prerequisite already required for
multi-replica high availability — a
shared external PostgreSQL endpoint every replica connects to. A
single-node deployment with an external database, and a multi-replica HA
deployment, differ only in how many backend-api instances point at that
same database.
For the PostgreSQL-server side of this — installation via the official PGDG
repositories, network/TLS hardening, pg_hba.conf, streaming replication
and automated-failover tooling for HA, and physical backup/WAL-archiving/PITR
on top of Repod's own pg_dump-based backup — see
External PostgreSQL (install, harden, HA, backup).
Step 3 — Create the data volume structure¶
mkdir -p /opt/repod/repos/{audit,auth,backups,certs,clamav-db,conf,db,dists,\
gnupg,grype-db,imports,logs,manifests,maven,npm,package-index,pool,pypi,rpm,\
apk,secrets,security,settings,staging/incoming,staging/quarantine,templates,\
upstream-cache}
| Directory | Contents |
|---|---|
audit/ |
Append-only JSONL audit logs (one file per day) |
auth/ |
Password-reset tokens |
backups/ |
Scheduled backup archives (pg_dump + config) |
certs/ |
Auto-signed TLS certificate + LDAP CA, if used |
clamav-db/ |
ClamAV signature database (~800 MB) |
conf/ |
reprepro distribution configuration (APT mode) |
db/ |
reprepro internal database (APT mode) |
dists/ |
APT distribution trees, served by depot-apt |
rpm/ |
RPM distribution trees (<codename>/<arch>/repodata/), served by depot-rpm |
apk/ |
Alpine repositories (<codename>/main/<arch>/APKINDEX.tar.gz), served by depot-apt under /apk/ |
gnupg/ |
GPG keyring shared between backend and repository containers |
grype-db/ |
Grype CVE database cache |
imports/ |
Working directory for sync/mirror imports |
logs/ |
Nginx download logs (parsed for statistics) |
manifests/ |
Per-package JSON manifests and central index.json |
maven/ |
Maven artifacts (GAV layout), generated checksums and maven-metadata.xml |
npm/ |
npm tarballs, by namespace |
pool/ |
.deb / .rpm package binaries (canonical store) |
pypi/ |
PyPI wheels/sdists, by index |
secrets/ |
Auto-generated secrets (JWT signing key, etc.) — must persist, or every restart regenerates them and invalidates existing sessions |
security/ |
CVE decisions, CISA KEV and EPSS caches |
settings/ |
Runtime settings (settings.json) |
staging/ |
Upload landing zone and quarantine |
templates/ |
Customisable notification email templates |
upstream-cache/ |
Generated nginx config + cached files for the upstream cache |
maven/, npm/, and pypi/ are not created automatically
Unlike the OS-package directories, these three are not provisioned by any startup code — only their permissions get fixed if the directory already exists. If you skip creating them before first use, publishing a Maven, PyPI, or npm package will fail.
User accounts, the manifest index, inventory data, and SSH host-key fingerprints
are not stored under /repos/ — they live in PostgreSQL, in the
postgres_data Docker volume managed by the db service. No manual directory
creation is needed for the database; docker compose up creates the volume
automatically and Alembic runs migrations at backend startup.
Container images (OCI) and MinIO are not part of this list
The optional OCI container registry overlay (docker-compose.oci.yml)
manages its own image storage inside the registry container, not under
/repos/. See Container Registry.
Step 4 — Build and start the services¶
With the default REPO_FORMAT=all, Docker builds and starts five containers:
| Container | Role | Port |
|---|---|---|
repod-db |
PostgreSQL 16 — application database | (internal only) |
depot-apt |
Nginx — APT (.deb) + Alpine (.apk) repositories |
80 |
depot-rpm |
Nginx — RPM (.rpm) repositories |
8080 |
backend-api |
FastAPI, security pipeline | 8000 |
frontend-ui |
React web interface + Nginx | 3003 |
Verify all containers are running and healthy:
Monitor startup logs:
First startup is slower
ClamAV loads ~800 MB of signatures on first start (up to 2 minutes — the
health endpoint may report "clamav": false until this completes). PostgreSQL
also runs its own first-time initialization and Alembic applies migrations.
This is normal.
Step 5 — Configure the GPG signing key¶
The repository indexes must be GPG-signed for APT/RPM/APK clients to verify packages.
- Open
http://YOUR_HOST:3003in a browser - Log in with the admin account created via the setup wizard
- Go to Settings → GPG
- Click Generate GPG Key
- Fill in the real name and email address, then click Generate
docker exec backend-api gpg --homedir /repos/gnupg \
--batch --gen-key <<EOF
%no-protection
Key-Type: RSA
Key-Length: 4096
Name-Real: Repod Repository
Name-Email: repod@example.com
Expire-Date: 2y
%commit
EOF
# Initialize distributions after key generation
TOKEN=$(curl -s -X POST http://localhost:8000/api/v1/auth/token \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"YourPassword!"}' | jq -r .access_token)
curl -X POST http://localhost:8000/api/v1/distributions/init \
-H "Authorization: Bearer $TOKEN"
Step 6 — Verify the installation¶
# 1. All containers running and healthy
docker compose ps
# 2. API liveness probe
curl -s http://localhost:8000/health/live
# Expected: {"status":"ok"}
# 3. API full health check (includes PostgreSQL, ClamAV, and Grype status)
TOKEN=$(curl -s -X POST http://localhost:8000/api/v1/auth/token \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"YourPassword!"}' | jq -r .access_token)
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8000/health | jq .
# 4. Web interface reachable
curl -s -o /dev/null -w "%{http_code}" http://localhost:3003
# Expected: 200
# 5. APT/APK repository server reachable
curl -s -o /dev/null -w "%{http_code}" http://localhost:80
# Expected: 200
# 6. RPM repository server reachable (REPO_FORMAT=rpm/both/all)
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080
# Expected: 200
# 7. Swagger UI disabled in production
curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/docs
# Expected: 404
Step 7 — Configure the firewall¶
sudo firewall-cmd --permanent --add-service=ssh
sudo firewall-cmd --permanent --add-port=3003/tcp # Web interface
sudo firewall-cmd --permanent --add-port=80/tcp # APT / APK clients
sudo firewall-cmd --permanent --add-port=8080/tcp # RPM clients
# Port 8000 — do NOT open; use a reverse proxy
sudo firewall-cmd --reload
sudo firewall-cmd --list-all
Never expose port 8000 directly
The backend API (port 8000) must be accessible only through a TLS reverse proxy. Direct public exposure transmits credentials and tokens in plaintext. See Reverse proxy →
Step 8 — Enable systemd auto-start¶
cat > /etc/systemd/system/repod.service << 'EOF'
[Unit]
Description=Repod Package Repository Manager
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/repod
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=300
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable repod
sudo systemctl start repod
Alternative: standalone RPM-only stack¶
If you only need an RPM repository and want it fully isolated (own PostgreSQL
instance, own network, own container names — no shared state with an
APT/all-mode deployment), use the dedicated compose file instead of Step 4:
This starts repod-db-rpm (PostgreSQL), depot-rpm, backend-api-rpm
(REPO_FORMAT=rpm), and frontend-ui-rpm, on ports RPM_REPO_PORT (default
8080), BACKEND_PORT (default 8001), and FRONTEND_PORT (default 3004).
Run it standalone — do not merge it with docker-compose.yaml via -f.
High availability (Enterprise)¶
For deployments that can't tolerate a single point of failure, backend-api
can run as multiple replicas in an active/passive configuration behind a
load balancer:
- On startup, each replica attempts to acquire a shared lock against
PostgreSQL. The one that succeeds becomes the leader — only the leader
runs scheduled jobs (security sync, backups, retention, SLA checks, drift
scans) and accepts requests that start a long-running background job
(imports, installs, inventory scans). Passive replicas return
503on those specific endpoints, so a load balancer or client can retry against the leader. - The lock is tied to the leader's own database session — if that process dies, PostgreSQL releases the lock automatically and another replica acquires it. Failover is simply restarting the failed leader's container.
GET /healthexposes which replica is currently the leader (checks.info.ha), for operational visibility.
This requires an external HA PostgreSQL endpoint (not the single-node db
container from the base docker-compose.yaml) and a shared, read-write
filesystem for /repos across every replica (NFS, EFS, Filestore, or
equivalent) — see docker-compose.ha.yml for a documented starting overlay.
Post-deployment checklist¶
| Step | Command / File | Status |
|---|---|---|
DATABASE_URL configured, password matches POSTGRES_PASSWORD |
backend.env / .env |
|
REPO_FORMAT set to the desired value (apt/rpm/apk/both/all) |
backend.env |
|
| JWT secret key configured | JWT_SECRET_KEY in backend.env |
|
| Settings encryption key configured | SETTINGS_ENCRYPTION_KEY in backend.env |
|
| Webhook secret configured | WEBHOOK_SECRET in backend.env |
|
| Admin account created | Setup wizard (POST /api/v1/setup) or ADMIN_PASSWORD_HASH in backend.env |
|
BIND_HOST restricted |
BIND_HOST=127.0.0.1 in .env |
|
| CORS origins set | CORS_ORIGINS=https://… in backend.env |
|
ENV=production |
ENV=production in backend.env |
|
| GPG key generated | Settings → GPG in the web UI | |
| Firewall configured | Port 8000 not exposed publicly | |
| Reverse proxy with TLS | See reverse proxy guide | |
| Outbound proxy configured (if egress is restricted) | See proxy configuration guide | |
| Automated backups | See backup guide | |
| Systemd service enabled | systemctl enable repod |