self-hosting
Reverse Proxy
Nginx and Caddy configuration for BurnerByte.
Nginx Proxy Manager
The fields, for both hosts:
| Field | API host | Frontend host |
|---|---|---|
| Domain Names | api.example.com | mail.example.com |
| Scheme | http | http |
| Forward Hostname / IP | the container or host address | same |
| Forward Port | 8080 | 3000 |
| Websockets Support | On | On |
| Block Common Exploits | On | On |
| Cache Assets | Off | Off — Next.js sets its own cache headers |
Websockets Support belongs on the API host, not the frontend host. The
browser opens its WebSocket against the API origin, so enabling the toggle on
mail.example.com and leaving it off on api.example.com produces a UI that
loads perfectly and never receives a live message. The console shows a failed
upgrade and nothing appears in the API log, because the request never arrived.
Then SSL → Request a new SSL Certificate, with Force SSL and HTTP/2 Support enabled. Use a DNS challenge if port 80 is not reachable.
Finally, paste the directives from Header Hardening into Advanced → Custom Nginx Configuration on the API host, along with:
proxy_read_timeout 86400;proxy_send_timeout 86400;NPM's generated config appends to whatever the client sent and never clears
True-Client-IP, so the hardening is not optional here.
Nginx
server { listen 443 ssl; server_name api.example.com; ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem; location / { proxy_pass http://127.0.0.1:8080; 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; } # WebSocket support location /api/v1/ws/ { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; 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 86400; proxy_send_timeout 86400; }} server { listen 443 ssl; server_name app.example.com; ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem; location / { proxy_pass http://127.0.0.1:3000; 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; }}Caddy
api.example.com { reverse_proxy localhost:8080} app.example.com { reverse_proxy localhost:3000}Caddy automatically handles TLS certificates and WebSocket upgrades.
Traefik
As labels on the services in your Compose file:
services: api: labels: - "traefik.enable=true" - "traefik.http.routers.bb-api.rule=Host(`api.example.com`)" - "traefik.http.routers.bb-api.entrypoints=websecure" - "traefik.http.routers.bb-api.tls.certresolver=le" - "traefik.http.services.bb-api.loadbalancer.server.port=8080" - "traefik.http.routers.bb-api.middlewares=bb-realip@docker" - "traefik.http.middlewares.bb-realip.headers.customrequestheaders.True-Client-IP=" frontend: labels: - "traefik.enable=true" - "traefik.http.routers.bb-web.rule=Host(`mail.example.com`)" - "traefik.http.routers.bb-web.entrypoints=websecure" - "traefik.http.routers.bb-web.tls.certresolver=le" - "traefik.http.services.bb-web.loadbalancer.server.port=3000"Traefik forwards WebSocket upgrades without configuration, and sets X-Real-IP
and X-Forwarded-For from the connection itself — so the only header needing
explicit treatment is True-Client-IP, cleared above. Set
--entrypoints.websecure.forwardedHeaders.trustedIPs if Traefik itself sits
behind another proxy.
SMTP
The SMTP server (port 2525) should not be behind a reverse proxy. Use iptables to forward port 25:
sudo iptables -t nat -A PREROUTING -p tcp --dport 25 -j REDIRECT --to-port 2525Header Hardening
The API applies chi's RealIP middleware to every request, which overwrites
RemoteAddr from True-Client-IP, then X-Real-IP, then the first entry of
X-Forwarded-For — in that order, with no trust check. Both the /metrics
loopback/private-IP guard and the per-IP rate limiter read that value, so the
proxy must overwrite all three headers, not merely set some of them.
$proxy_add_x_forwarded_for appends to whatever the client sent, and neither
config above clears True-Client-IP, which chi consults first.
Add to every nginx location block:
proxy_set_header True-Client-IP ""; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr;Caddy's bare reverse_proxy sets none of these, so it needs the equivalent:
api.example.com { reverse_proxy localhost:8080 { header_up True-Client-IP "" header_up X-Real-IP {remote_host} header_up X-Forwarded-For {remote_host} }}Rebuild the Frontend for TLS
NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WS_URL and NEXT_PUBLIC_SITE_URL are
inlined into the client bundle at build time — they are Docker build args,
not runtime environment. Terminating TLS at the proxy is therefore not enough:
an image built with ws:// keeps dialling ws:// from an https:// page, and
the browser blocks it as mixed content. Set the public URLs and rebuild:
API_BASE_URL=https://api.example.comWS_BASE_URL=wss://api.example.comFRONTEND_URL=https://app.example.com docker compose build frontend && docker compose up -d frontend