Skip to content

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:

.env
BIND_HOST=127.0.0.1

Rebuild and restart after changing .env:

docker compose up -d

Install Nginx and Certbot

sudo apt install -y nginx certbot python3-certbot-nginx
sudo dnf install -y nginx certbot python3-certbot-nginx
sudo systemctl enable --now nginx

Obtain the TLS certificate

certbot --nginx -d repo.example.com

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):

/etc/nginx/sites-available/repod
# 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:

.env
REACT_APP_API_URL=https://repo.example.com/api
docker compose build frontend-ui
docker compose up -d frontend-ui

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:

ssl_certificate     /etc/ssl/certs/repod.crt;
ssl_certificate_key /etc/ssl/private/repod.key;

Distribute the certificate to clients

# On each client machine
sudo cp repod.crt /usr/local/share/ca-certificates/repod.crt
sudo update-ca-certificates

# For apt specifically
echo 'Acquire::https::repo.example.com::CaInfo "/etc/ssl/certs/repod.crt";' \
  | sudo tee /etc/apt/apt.conf.d/99repod-tls
# On each client machine
sudo cp repod.crt /etc/pki/ca-trust/source/anchors/repod.crt
sudo update-ca-trust extract

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.

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

docker-compose.yaml (additions)
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
docker-compose.yaml (frontend-ui and backend-api labels)
  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:

  1. Start Repod with the base compose file only — no TLS/proxy overlay:

    docker compose -f docker-compose.yaml up -d
    
  2. Restrict frontend-ui/backend-api to the network segment their Traefik can reach (BIND_HOST in .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.

  3. 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's frontend-ui labels, pointed at their own certresolver/rule).

  4. Hand them these three requirements — they're easy to miss and each one breaks something different:

    Requirement Why
    CORS_ORIGINS=https://repod.customer.com in backend.env Without it, the frontend's own API calls are rejected by the browser
    Forward the Host header Traefik does this by default (unlike Nginx, which needs an explicit proxy_set_header Host $host) — matters for SaaS tenant resolution, which reads this header
    No request body size limit on the Repod route .deb/.rpm uploads can be large (up to 512 MB); Traefik has no default limit, but a middleware they already run elsewhere might impose one

    No change is needed to Repod's own internal Nginx (depot-apt/ depot-rpm) — those containers keep handling their own auth_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.

curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
  | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
  | tee /etc/apt/sources.list.d/caddy-stable.list
apt update && apt install caddy
dnf install -y 'dnf-command(copr)'
dnf copr enable @caddy/caddy
dnf install caddy
/etc/caddy/Caddyfile
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 }
}
sudo systemctl enable --now caddy
caddy reload --config /etc/caddy/Caddyfile

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

.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

backend.env
CORS_ORIGINS=https://repo.example.com
TRUSTED_PROXIES=127.0.0.1

For Traefik inside Docker (bridge network), also include the Docker subnet:

TRUSTED_PROXIES=127.0.0.1,172.16.0.0/12

Rebuild the frontend

REACT_APP_* variables are baked into the JavaScript bundle at build time:

docker compose build frontend-ui
docker compose up -d frontend-ui

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:

systemctl status certbot.timer
certbot renew --dry-run   # test renewal without touching files

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