Reverse Proxy and TLS for APT Repo Manager¶
This guide explains how to place APT Repo Manager behind a reverse proxy to enable HTTPS, strengthen security, and expose the application on standard ports (80/443).
1. Why a reverse proxy?¶
APT Repo Manager exposes three ports over plain HTTP by default:
| Service | Port | Description |
|---|---|---|
| Frontend | 3003 | React SPA |
| Backend API | 8000 | FastAPI, called directly by browsers |
| APT repo | 80 | Served by internal Nginx |
Without a reverse proxy, the application runs over cleartext HTTP. This means:
- Credentials and API tokens are transmitted in plaintext over the network.
- Modern browsers flag or block plain HTTP sites.
- HSTS (HTTP Strict Transport Security) cannot be enabled.
aptclients 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.
- Exposure on standard ports 80/443 (no port number in the URL).
- Centralized certificate management (Let's Encrypt or internal CA).
- Ability to filter, rate-limit, and log requests.
2. Target architecture¶
ββββββββββββββββββββββββββββββββββββββββ
β Reverse Proxy β
Internet ββββ :443 ββββΊ β / β frontend :3003 β
β /api/ β backend :8000 β
ββββ :80 ββββΊ β apt.* β apt-repo :80 (HTTP OK) β
ββββββββββββββββββββββββββββββββββββββββ
- The frontend and API are served over HTTPS on the same domain (
repo.example.com). - The APT repo can remain on HTTP (packages are signed by GPG) or be migrated to HTTPS if apt clients support it.
- In production, set
BIND_HOST=127.0.0.1in.envso services only listen on the loopback interface.
3. Prerequisites and DNS¶
System prerequisites¶
- A Linux server with Docker and Docker Compose installed.
- Ports 80 and 443 open in the firewall.
- A domain name pointing to the server's public IP.
Recommended DNS setup¶
| Record | Value | Purpose |
|---|---|---|
repo.example.com |
A β SERVER_IP |
Frontend + API |
apt.example.com |
A β SERVER_IP |
APT repo (optional) |
If you use a single domain with path-based routing (/api/), a single A record is sufficient.
Firewall ports¶
# ufw
ufw allow 80/tcp
ufw allow 443/tcp
# iptables
iptables -A INPUT -p tcp --dport 80 -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -j ACCEPT
4. Option A: Nginx + Let's Encrypt (recommended)¶
4.1 Installation¶
4.2 Obtaining the certificate¶
certbot --nginx -d repo.example.com
# If using a separate subdomain for apt:
certbot --nginx -d repo.example.com -d apt.example.com
Certbot automatically modifies the Nginx configuration and sets up auto-renewal.
4.3 Complete Nginx configuration¶
Create the file /etc/nginx/sites-available/repod:
# /etc/nginx/sites-available/repod
# HTTP β HTTPS redirect (frontend + API)
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 (1 year β do not enable before 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
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 long CVE scans
client_max_body_size 512m; # allow large .deb package uploads
}
}
# APT repo (HTTP β apt clients typically don't have custom CAs)
server {
listen 80;
server_name apt.example.com;
location / {
proxy_pass http://127.0.0.1:80;
proxy_set_header Host $host;
}
}
4.4 Enabling the configuration¶
ln -s /etc/nginx/sites-available/repod /etc/nginx/sites-enabled/repod
nginx -t # syntax check
systemctl reload nginx
4.5 Variant: single domain with /api/ path prefix¶
If you do not want a separate subdomain for the API, the location /api/ block above handles it. Update REACT_APP_API_URL accordingly:
5. Option B: Nginx + internal CA / self-signed certificate¶
Useful for intranets or development environments without public Internet access.
5.1 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"
To include Subject Alternative Names (recommended for modern browsers):
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"
5.2 Adapt the Nginx configuration¶
Replace the ssl_certificate / ssl_certificate_key lines with:
Remove or comment out the HSTS header if the certificate is not publicly trusted.
5.3 Distributing the certificate to clients¶
For browsers and apt clients to accept the certificate:
# On each client machine (Ubuntu/Debian)
cp repod.crt /usr/local/share/ca-certificates/repod.crt
update-ca-certificates
# For apt specifically
echo 'Acquire::https::repo.example.com::CaInfo "/etc/ssl/certs/repod.crt";' \
> /etc/apt/apt.conf.d/99repod-tls
6. Option C: Traefik (Docker-native)¶
Traefik integrates natively with Docker and automatically obtains Let's Encrypt certificates via labels on containers.
6.1 Traefik service in docker-compose.yaml¶
services:
traefik:
image: traefik:v3.0
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.email=admin@example.com"
- "--certificatesresolvers.letsencrypt.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
6.2 Labels on repod services¶
frontend:
labels:
- "traefik.enable=true"
- "traefik.http.routers.repod-frontend.rule=Host(`repo.example.com`)"
- "traefik.http.routers.repod-frontend.entrypoints=websecure"
- "traefik.http.routers.repod-frontend.tls.certresolver=letsencrypt"
- "traefik.http.services.repod-frontend.loadbalancer.server.port=3003"
backend:
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=letsencrypt"
- "traefik.http.routers.repod-api.middlewares=strip-api-prefix"
- "traefik.http.middlewares.strip-api-prefix.stripprefix.prefixes=/api"
- "traefik.http.services.repod-api.loadbalancer.server.port=8000"
6.3 Pros and cons¶
| Advantage | Disadvantage |
|---|---|
| Zero manual certificate management | Traefik needs access to the Docker socket |
| Automatic renewal | Verbose labels for complex routing rules |
| Built-in dashboard | Less suitable for non-Docker deployments |
7. Option D: Caddy (minimal configuration)¶
Caddy handles HTTPS automatically with almost no configuration.
7.1 Installation¶
apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
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
7.2 Caddyfile¶
# /etc/caddy/Caddyfile
repo.example.com {
# Frontend
reverse_proxy / http://127.0.0.1:3003
# Backend API (strip the /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
}
}
}
# Maximum upload size for .deb packages
request_body {
max_size 512MB
}
}
apt.example.com {
reverse_proxy http://127.0.0.1:80
}
7.3 Starting Caddy¶
Caddy automatically obtains and renews Let's Encrypt certificates in the background. No further action is required.
8. Updating repod configuration after enabling HTTPS¶
After enabling the reverse proxy, several environment variables need to be updated.
8.1 .env file (project root)¶
# Restrict binding to loopback β the RP handles external exposure
BIND_HOST=127.0.0.1
# Public URLs (used at frontend build time)
PUBLIC_URL=https://repo.example.com
REACT_APP_API_URL=https://repo.example.com/api
REACT_APP_REPO_URL=http://apt.example.com
8.2 backend.env file¶
# Only allow requests from the new HTTPS domain
CORS_ORIGINS=https://repo.example.com
# Trust only the local reverse proxy
TRUSTED_PROXIES=127.0.0.1
For Traefik inside Docker (bridge network), also include the Docker subnet:
8.3 Application settings¶
In the web UI: Settings > General > app_url β https://repo.example.com
8.4 Rebuilding the frontend¶
The React frontend is compiled with environment variables at build time. After any change to REACT_APP_* or PUBLIC_URL, rebuild the image:
9. Verification and testing¶
9.1 SSL verification¶
# Check the certificate chain
curl -vI https://repo.example.com 2>&1 | grep -E "SSL|TLS|certificate|issuer|expire"
# Check with openssl
openssl s_client -connect repo.example.com:443 -servername repo.example.com < /dev/null \
| openssl x509 -noout -dates -subject -issuer
9.2 API test¶
# Verify the API responds over HTTPS
curl -s https://repo.example.com/api/health | jq .
# Check the HSTS header
curl -sI https://repo.example.com | grep -i strict-transport
9.3 HTTP β HTTPS redirect test¶
9.4 Browser developer tools check¶
- Open
https://repo.example.comin Chrome or Firefox. - Open DevTools β Network tab β click the first request.
- Verify in Response Headers:
strict-transport-security: max-age=31536000; includeSubDomainsx-forwarded-proto: https(visible in backend logs if logging is enabled)
9.5 APT client test¶
# Add the repository and test
echo "deb http://apt.example.com/ubuntu focal main" \
> /etc/apt/sources.list.d/repod.list
apt update
9.6 SSL quality rating (optional)¶
Test your TLS configuration at https://www.ssllabs.com/ssltest/ to obtain an A+ rating.
10. Certificate renewal¶
10.1 Let's Encrypt (Certbot)¶
Certbot automatically configures a systemd timer or cron job for renewal:
# Check the systemd timer
systemctl status certbot.timer
# Dry-run renewal test
certbot renew --dry-run
# Force renewal (if needed)
certbot renew --force-renewal
The default cron added by certbot:
After renewal, Nginx reloads its configuration automatically if the post-deploy hook is in place (certbot adds it by default).
10.2 Traefik¶
Traefik handles renewal automatically. No manual action is required. Certificates are stored in ./letsencrypt/acme.json.
10.3 Caddy¶
Caddy renews certificates automatically in the background. No manual action is required.
10.4 Internal / self-signed certificates¶
For a self-signed certificate with a 1-year validity period, schedule a cron job for renewal:
# Example renewal script
cat > /usr/local/bin/renew-repod-cert.sh << 'EOF'
#!/bin/bash
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"
systemctl reload nginx
EOF
chmod +x /usr/local/bin/renew-repod-cert.sh
# Annual cron (330 days to stay ahead of expiry)
echo "0 3 1 1 * root /usr/local/bin/renew-repod-cert.sh" \
> /etc/cron.d/repod-cert-renewal
Post-deployment checklist¶
| Action | Command / File |
|---|---|
| Restrict binding | BIND_HOST=127.0.0.1 in .env |
| Update public URLs | PUBLIC_URL, REACT_APP_API_URL in .env |
| Update CORS | CORS_ORIGINS=https://... in backend.env |
| Configure trusted proxies | TRUSTED_PROXIES=127.0.0.1 in backend.env |
Update app_url |
Settings > General in the web UI |
| Rebuild frontend | docker compose build frontend && docker compose up -d frontend |
| Verify HSTS header | Browser DevTools β Response Headers |