Skip to content
BurnerByte

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

ServicePublished as
frontend :3000The 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 :8080REST under /api/v1, WebSockets under /api/v1/ws/, and /healthz, /readyz, /metrics at the root.
smtpd :25Not 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:

FieldAPI hostFrontend host
Domain Namesapi.example.commail.example.com
Schemehttphttp
Forward Hostname / IPthe container or host addresssame
Forward Port80803000
Websockets SupportOnOn
Block Common ExploitsOnOn
Cache AssetsOffOff — Next.js sets its own cache headers
Careful

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:

nginx
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

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

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

docker-compose.override.yml
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:

bash
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 save

Header Hardening

Careful

Add to every nginx location block:

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

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

bash
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

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

docker-compose.override.yml
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.

Note

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.

.env
# 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/32

Getting 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

bash
# 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/notifications
Note