Reverse Proxy
Nginx Proxy Manager, nginx, Caddy and Traefik in front of BurnerByte — including the WebSocket upgrade and the forwarded-header hardening rate limiting depends on.
What Needs Publishing
| Service | Published as |
|---|---|
frontend :3000 | The web UI. Next.js in standalone mode — a Node server, not static files, so it needs a real upstream rather than a document root. |
api :8080 | REST under /api/v1, WebSockets under /api/v1/ws/, and /healthz, /readyz, /metrics at the root. |
smtpd :25 | Not proxied. SMTP is not HTTP — see below. |
Two hostnames is the arrangement used throughout: mail.example.com for the UI
and api.example.com for the API. A single host with a path split also works,
but then API_BASE_URL has to carry the path.
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 Cannot Be Proxied or Tunnelled
This constrains the whole deployment, so it is worth stating before the rest.
Mail arrives on port 25, from arbitrary senders, over plain TCP. An HTTP reverse proxy has nothing useful to do with it, and neither does a tunnel:
- Cloudflare Tunnel carries HTTP and HTTPS. Arbitrary TCP is Spectrum, an enterprise product, and Spectrum does not offer port 25 in any case — Cloudflare does not proxy SMTP.
- A VPN does not help either. Senders are strangers on the internet; they cannot join your WireGuard network to deliver a message.
So whatever you do with the web UI, the MX target resolves to an address that reaches the SMTP daemon directly. If hiding your origin IP was the point of the tunnel, note that the MX record publishes it anyway. The alternatives are a relay in front that forwards inbound mail to a port of your choosing, or accepting that the instance only receives from inside your own network — which is more often acceptable than people expect, when the inboxes exist to catch mail your own systems generate.
nginx's stream module can forward TCP if you genuinely need a hop, but it
costs you the real client IP unless you also configure PROXY protocol, which
the SMTP daemon does not parse.
Publish the port directly. If you need to bind 25 without running the daemon as root, redirect on the host:
sudo iptables -t nat -A PREROUTING -p tcp --dport 25 -j REDIRECT --to-port 2525 # persist it across rebootssudo apt install -y iptables-persistent && sudo netfilter-persistent saveHeader 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 frontendNever Put an Identity Proxy in Front of the API
Cloudflare Access, oauth2-proxy, Authelia and anything else that gates a request with an HTML login page will break every non-browser caller: API clients, the frontend's own fetches, and the WebSocket handshake all receive that page instead of their response.
Gate paths under the frontend hostname if you want a second authentication
layer — /admin is the surface where a compromised session does the most damage
and the most sensible thing to gate. Leave the API hostname ungated; it has its
own authentication, which is the layer designed for machine callers.
Long-Lived WebSockets
Live mail delivery rides on a WebSocket, and every proxy and tunnel closes idle
connections by default — nginx at 60 seconds, Cloudflare Tunnel around 90. The
client reconnects, so nothing breaks outright, but a mailbox open on a quiet
afternoon visibly stutters. Raise proxy_read_timeout and proxy_send_timeout
on the WebSocket location, or set tcpKeepAlive in a tunnel's
originRequest block.
Binding to a Private Address
If the web UI should only be reachable over a VPN, bind it to the VPN address
rather than firewalling it. Docker's port publishing punches through ufw, so
restricting the bind is the reliable move:
services: frontend: ports: - "10.8.0.1:3000:3000" api: ports: - "10.8.0.1:8080:8080" smtpd: ports: # The one thing that must stay public. - "0.0.0.0:25:2525"Set FRONTEND_URL, API_BASE_URL and WS_BASE_URL to the VPN addresses and
rebuild the frontend — they are build args, not runtime configuration. Plain
HTTP inside a WireGuard tunnel is defensible: the transport is already
authenticated and encrypted.
/metrics answers loopback and RFC1918 addresses only, and a VPN subnet such as
10.8.0.0/24 qualifies — which is the strongest argument for putting operators
on a private network. See Monitoring.
Trusted Proxies
Separately from the headers, tell the rate limiter which upstream it should believe. Left empty — the default — forwarded headers are ignored entirely and the client IP is always the peer address, which is correct for a directly-exposed deployment and wrong behind a proxy.
# Your proxy's address or CIDR. Behind a proxy and unset, every request appears# to come from the proxy and all per-client rate limiting collapses onto a# single bucket.BB_RATE_LIMIT_TRUSTED_PROXIES=172.16.0.0/12,192.168.1.5/32Getting this wrong costs accuracy rather than safety: forwarded headers from anywhere other than a trusted proxy are ignored by design, so a client still cannot choose its own identity.
Verifying
# TLS terminates and the API answerscurl -fsS https://api.example.com/healthz # The UI loads over TLScurl -sI https://mail.example.com | head -1 # The WebSocket upgrades — 101 is what you want. Anything else (404, 502, 200)# means the upgrade is not being forwarded.curl -sI -o /dev/null -w '%{http_code}\n' \ -H "Connection: Upgrade" -H "Upgrade: websocket" \ -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ https://api.example.com/api/v1/ws/notificationsThe real test: open the UI in a browser, create an inbox, and send it a message
from another machine. If it appears without a refresh, the WebSocket, the headers
and the frontend build are all correct. If you have to refresh to see it, start
with WS_BASE_URL and the frontend rebuild.