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)

Traefik integrates natively with Docker and automatically obtains Let's Encrypt certificates via labels on containers.

Add Traefik to docker-compose.yaml

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

Add labels to Repod services

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

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