Skip to content

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 sudo access

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)

cp .env.example .env
nano .env

Typical content — replace repo.example.com with your actual domain or IP:

.env
# 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)

cp backend.env.example backend.env

Generate the required secrets (one openssl rand -hex 32 per secret):

openssl rand -hex 32
backend.env
# ── 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:

chmod 600 /opt/repod/.env
chmod 600 /opt/repod/backend.env

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:

python3 -c "from passlib.hash import bcrypt; print(bcrypt.hash('YourPassword!'))"

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:

docker-compose.yaml (excerpt)
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:

  1. Provision the database — PostgreSQL 16 or later, a repod database and a role with full privileges on it, reachable from the backend-api container over the network (same host, private network, or VPN — never expose PostgreSQL directly to the internet).
  2. Edit docker-compose.yaml: replace the hardcoded DATABASE_URL line in the backend service with your external connection string (or remove the line entirely and rely on backend.env's DATABASE_URL instead, since nothing then overrides it), and delete the db: service block — it would otherwise start and sit unused.
  3. Remove the db service's dependency — the backend service's depends_on: [db] (and its condition: service_healthy, if present) must be removed along with the db: block, or Compose will refuse to start.
  4. Continue with Step 3 below as normal — schema creation (Alembic migrations, run automatically by entrypoint.sh on 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

cd /opt/repod
docker compose up -d --build

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:

docker compose ps

Monitor startup logs:

docker compose logs -f backend-api
# Wait for: INFO:     Application startup complete.

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.

  1. Open http://YOUR_HOST:3003 in a browser
  2. Log in with the admin account created via the setup wizard
  3. Go to Settings → GPG
  4. Click Generate GPG Key
  5. 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 ufw enable
sudo ufw allow 22/tcp          # SSH — do not lock yourself out
sudo ufw allow 3003/tcp        # Web interface
sudo ufw allow 80/tcp          # APT / APK clients
sudo ufw allow 8080/tcp        # RPM clients (skip if REPO_FORMAT=apt)
# Port 8000 — do NOT open publicly; use a reverse proxy
sudo ufw status numbered
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:

docker compose -f docker-compose.rpm.yml up -d --build

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 503 on 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 /health exposes 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

Next steps