Reverse Proxy & TLS¶
Configure a TLS-terminating reverse proxy in front of Repod to enable HTTPS, expose services on standard ports, and harden the public attack surface.
Why a reverse proxy?¶
Repod exposes three services over plain HTTP by default:
| Service | Default port | Description |
|---|---|---|
| Web interface | 3003 |
React SPA |
| Backend API | 8000 |
FastAPI — called directly by browsers |
| Repository | 80 |
Served by internal Nginx |
Without a reverse proxy:
- Credentials and API tokens travel over the network in plaintext.
- Modern browsers flag plain HTTP sites.
- HSTS cannot be enabled.
- APT/DNF clients are vulnerable to man-in-the-middle attacks.
With a TLS reverse proxy you gain:
- End-to-end encryption (TLS 1.2/1.3).
- HSTS to enforce HTTPS in browsers.
- Standard ports 80/443 — no port suffix in URLs.
- Centralized certificate management (Let's Encrypt or internal CA).
Target architecture¶
┌───────────────────────────────────────┐
│ Reverse Proxy │
Internet ─ :443 ─► │ / → frontend-ui :3003 │
│ /api/* → backend-api :8000 │
─ :80 ─► │ redirect to HTTPS │
└───────────────────────────────────────┘
┌───────────────────────────────────────┐
│ Repository (HTTP OK) │
LAN clients ─ :80 ─► depot-apt / depot-rpm :80 │
└───────────────────────────────────────┘
Repository over HTTP
The APT/RPM repository can remain on plain HTTP — packages are signed by GPG. APT and DNF verify the signature; HTTP integrity is handled at the package level. Migrate to HTTPS only if your security policy requires it.
Before you start¶
Set BIND_HOST=127.0.0.1 in your .env file so services bind only to the
loopback interface. The reverse proxy then handles all external exposure:
Rebuild and restart after changing .env:
Option A — Nginx + Let's Encrypt (recommended)¶
Install Nginx and Certbot¶
Obtain the TLS certificate¶
Certbot modifies the Nginx configuration and configures automatic renewal.
Full Nginx configuration¶
Create /etc/nginx/sites-available/repod (Debian/Ubuntu) or
/etc/nginx/conf.d/repod.conf (RHEL/AlmaLinux):
# HTTP → HTTPS redirect
server {
listen 80;
server_name repo.example.com;
return 301 https://$host$request_uri;
}
# Frontend + API over HTTPS
server {
listen 443 ssl http2;
server_name repo.example.com;
ssl_certificate /etc/letsencrypt/live/repo.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/repo.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
# HSTS — enable only after verifying HTTPS works end-to-end
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Frontend (React SPA)
location / {
proxy_pass http://127.0.0.1:3003;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Backend API — strip the /api prefix before forwarding
location /api/ {
rewrite ^/api/(.*) /$1 break;
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s; # allow time for CVE scans
client_max_body_size 512m; # allow large package uploads
# Required for Server-Sent Events (sync / import progress)
proxy_buffering off;
proxy_cache off;
}
}
Enable and reload:
# Debian / Ubuntu
ln -s /etc/nginx/sites-available/repod /etc/nginx/sites-enabled/repod
# All platforms
nginx -t
sudo systemctl reload nginx
Variant — single domain with /api/ path prefix¶
If you do not want the frontend and API on separate ports, the location /api/
block above routes API calls correctly. Update REACT_APP_API_URL accordingly
and rebuild the frontend:
Option B — Nginx + internal CA / self-signed certificate¶
Useful for intranets or air-gapped environments without public Internet access.
Generate a self-signed certificate¶
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/ssl/private/repod.key \
-out /etc/ssl/certs/repod.crt \
-subj "/CN=repo.example.com" \
-addext "subjectAltName=DNS:repo.example.com,IP:192.168.1.100"
Replace the ssl_certificate* lines in the Nginx configuration above:
Distribute the certificate to clients¶
Option C — Traefik (Docker-native)¶
Repod supports Traefik in three configurations, depending on who owns the Traefik instance: bundled with Repod, self-rolled with Docker labels, or an existing Traefik you don't control.
C1 — Repod's bundled overlay (recommended)¶
Repod ships a ready-to-use overlay, docker-compose.traefik.yml, plus
traefik/traefik.yml and traefik/dynamic.yml. It's mutually exclusive with
docker-compose.tls.yml (same role — pick one, never both) and reproduces
the same routing (frontend /, direct API on :8443) as the Nginx TLS
overlay.
bash scripts/gen-selfsigned-certs.sh
docker compose -f docker-compose.yaml -f docker-compose.traefik.yml up -d
Why the file provider, not Docker labels
This overlay deliberately uses Traefik's file provider
(traefik/dynamic.yml) instead of its Docker provider. The Docker
provider requires mounting /var/run/docker.sock into the proxy
container — a container with socket access can control every
container on the host, including other tenants' data in SaaS mode.
Repod avoids mounting the Docker socket everywhere else (see the HA and
OCI registry sections of the architecture docs), so the bundled overlay
follows the same rule. The trade-off: adding a new route means editing
traefik/dynamic.yml (hot-reloaded, no restart needed) instead of
adding a label — the same effort as maintaining an Nginx vhost.
Let's Encrypt is supported natively (see the commented block in
traefik/traefik.yml) — Traefik renews certificates itself, no separate
Certbot container required.
C2 — Self-rolled Traefik with Docker labels¶
If you'd rather use Traefik's Docker auto-discovery instead of the bundled file-provider overlay, here is the equivalent setup. This does require Docker socket access for the Traefik container — acceptable for a single-tenant, single-purpose host; weigh it more carefully for a multi-tenant/SaaS deployment (see the callout above).
services:
traefik:
image: traefik:v3.0
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.le.acme.tlschallenge=true"
- "[email protected]"
- "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./letsencrypt:/letsencrypt"
restart: unless-stopped
frontend-ui:
labels:
- "traefik.enable=true"
- "traefik.http.routers.repod-ui.rule=Host(`repo.example.com`)"
- "traefik.http.routers.repod-ui.entrypoints=websecure"
- "traefik.http.routers.repod-ui.tls.certresolver=le"
- "traefik.http.services.repod-ui.loadbalancer.server.port=3003"
backend-api:
labels:
- "traefik.enable=true"
- "traefik.http.routers.repod-api.rule=Host(`repo.example.com`) && PathPrefix(`/api/`)"
- "traefik.http.routers.repod-api.entrypoints=websecure"
- "traefik.http.routers.repod-api.tls.certresolver=le"
- "traefik.http.routers.repod-api.middlewares=strip-api"
- "traefik.http.middlewares.strip-api.stripprefix.prefixes=/api"
- "traefik.http.services.repod-api.loadbalancer.server.port=8000"
| Advantage | Disadvantage |
|---|---|
| Zero manual certificate management | Requires Docker socket access |
| Auto-renewal | Verbose label syntax |
| Auto-discovery of services | Extra container to manage |
C3 — Integrating with a Traefik you don't manage¶
Common when deploying on a customer's infrastructure: they already run Traefik (elsewhere on the same Docker host, on a separate host, or as a Kubernetes ingress) and want Repod to sit behind it rather than run its own proxy.
Don't deploy either of the overlays above (docker-compose.traefik.yml
or the labels in C2) — they'd conflict with the existing instance. Instead:
-
Start Repod with the base compose file only — no TLS/proxy overlay:
-
Restrict
frontend-ui/backend-apito the network segment their Traefik can reach (BIND_HOSTin.env, or firewall/network segmentation) — the frontend already proxies/api/to the backend internally, so only one upstream (:3003) needs to be reachable from their proxy. -
Give their team the route to add on their side. If they use Traefik's file provider, this is the whole snippet:
dynamic.yml (their Traefik)http: routers: repod: rule: "Host(`repod.customer.com`)" entryPoints: ["websecure"] service: repod tls: {} services: repod: loadBalancer: servers: - url: "http://<repod-host>:3003"If they use Docker labels instead, and their Traefik can reach Repod's Docker network, the equivalent is a single label block on
frontend-ui(same shape as C2'sfrontend-uilabels, pointed at their owncertresolver/rule). -
Hand them these three requirements — they're easy to miss and each one breaks something different:
Requirement Why CORS_ORIGINS=https://repod.customer.cominbackend.envWithout it, the frontend's own API calls are rejected by the browser Forward the HostheaderTraefik does this by default (unlike Nginx, which needs an explicit proxy_set_header Host $host) — matters for SaaS tenant resolution, which reads this headerNo request body size limit on the Repod route .deb/.rpmuploads can be large (up to 512 MB); Traefik has no default limit, but a middleware they already run elsewhere might impose oneNo change is needed to Repod's own internal Nginx (
depot-apt/depot-rpm) — those containers keep handling their ownauth_request-based distribution access checks regardless of what reverse proxy sits in front of the whole stack.
Option D — Caddy (minimal configuration)¶
Caddy handles HTTPS automatically with minimal configuration.
repo.example.com {
# Frontend
reverse_proxy / http://127.0.0.1:3003
# Backend API — strip /api prefix
handle /api/* {
uri strip_prefix /api
reverse_proxy http://127.0.0.1:8000 {
header_up X-Forwarded-Proto {scheme}
transport http { read_timeout 300s }
}
}
request_body { max_size 512MB }
}
Caddy obtains and renews Let's Encrypt certificates automatically.
Update Repod configuration after enabling HTTPS¶
After enabling the reverse proxy, update environment variables and rebuild the frontend.
.env¶
BIND_HOST=127.0.0.1
REACT_APP_API_URL=https://repo.example.com/api
REACT_APP_REPO_URL=http://repo.example.com
backend.env¶
For Traefik inside Docker (bridge network), also include the Docker subnet:
Rebuild the frontend¶
REACT_APP_* variables are baked into the JavaScript bundle at build time:
Verify the configuration¶
# Certificate chain
curl -vI https://repo.example.com 2>&1 | grep -E "SSL|TLS|certificate|issuer"
# HSTS header
curl -sI https://repo.example.com | grep -i strict-transport
# HTTP → HTTPS redirect
curl -I http://repo.example.com
# Expected: 301 Moved Permanently → https://...
# API over HTTPS
curl -s https://repo.example.com/api/health/live | jq .
Certificate renewal¶
| Proxy | Renewal | Action required |
|---|---|---|
| Certbot | Systemd timer every 12 h | None — runs automatically |
| Traefik | Built-in ACME client | None |
| Caddy | Built-in ACME client | None |
| Self-signed | Manual or cron | Schedule annual renewal |
Check Certbot timer status:
Post-configuration checklist¶
| Action | File / Command |
|---|---|
BIND_HOST=127.0.0.1 |
.env |
REACT_APP_API_URL updated |
.env → rebuild frontend |
CORS_ORIGINS updated |
backend.env |
TRUSTED_PROXIES set |
backend.env |
| Frontend rebuilt | docker compose build frontend-ui |
| HSTS header verified | curl -sI https://repo.example.com |
| Port 8000 not exposed | sudo ufw status or firewall-cmd --list-all |