Skip to content
BurnerByte

Reverse proxy and TLS

Two HTTP services need publishing and one does not. Getting TLS in front of them is the easy half; the half that bites is the WebSocket upgrade and the forwarded headers that rate limiting, the audit trail and the metrics guard all read.

Whichever proxy you use, four things have to be true: both HTTP services are published, the WebSocket endpoints upgrade cleanly, the client IP arriving at the API is the real one, and the frontend was built knowing it lives behind TLS. Miss the last and live mail stops arriving with nothing in any log to explain it.

The shape of it

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 the project documents and the one used throughout here: 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 and CORS has one fewer thing to get wrong — pick one and be consistent.

The configurations

Nginx Proxy Manager field by field, raw nginx, Caddy and Traefik are all in the Reverse Proxy reference, complete and copyable. They are not repeated here: a second copy of a server block is exactly the thing that goes stale in one place and misleads whoever finds that one.

Three details in there are worth knowing before you start, because each one produces a failure that looks like something else:

  • Nginx Proxy Manager’s Websockets toggle belongs on the API host. The browser opens its socket against the API origin. Enable it on the frontend host only and you get a UI that loads perfectly and never receives a live message, with nothing in the API log — because the request never arrived.
  • Every location block needs the header hardening. Caddy’s bare reverse_proxy sets none of those headers; nginx’s $proxy_add_x_forwarded_for appends rather than overwrites. Why that matters is below.
  • Raise the WebSocket timeouts. The default 60 seconds kills a long-lived connection; the client reconnects, and a mailbox open on a quiet afternoon visibly stutters.

Why the headers matter this much

The API applies chi’s RealIP middleware to every request. It overwrites the connection’s remote address from True-Client-IP, then X-Real-IP, then the first entry of X-Forwarded-For — in that order, with no trust check at the middleware level.

Four things then read that resolved value:

  • Per-IP rate limiting
  • The API-key IP allowlist
  • Every audit log entry
  • The /metrics guard, which answers loopback and RFC1918 addresses only
Overwrite all three, not some of them

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.

Rebuild the frontend for TLS

The one that catches everybody. 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 does not change what is already compiled into the JavaScript.

An image built with ws:// keeps dialling ws:// from an https:// page, and the browser blocks it as mixed content. The UI loads, sign-in works, everything looks correct, and mail never appears without a manual refresh.

bash
# In .env$ cat >> .env <<'ENV'FRONTEND_URL=https://mail.example.comAPI_BASE_URL=https://api.example.comWS_BASE_URL=wss://api.example.comENV # Rebuild, not restart.$ docker compose build frontend && docker compose up -d frontend

While you are there

.env
# CORS derives from FRONTEND_URL, so this is usually already right.# Set it explicitly if more than one origin needs access.CORS_ALLOWED_ORIGINS=https://mail.example.com # "none" forces Secure and therefore HTTPS. Only needed when the frontend and# API sit on genuinely unrelated domains; "lax" is right for the two-subdomain# arrangement above.BB_AUTH_COOKIE_SAME_SITE=lax

Never proxy SMTP

Port 25 does not go through nginx, Caddy, Traefik or a Cloudflare Tunnel. SMTP is not HTTP; an HTTP reverse proxy has nothing useful to do with it. Publish the port directly, and if you need to bind 25 without running the daemon as root, redirect on the host:

Publish the port directly. If you need to bind 25 without running the daemon as root, the host-level redirect is in the SMTP section of the reference.

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.

Verifying

bash
# TLS terminates and the API answers$ curl -fsS https://api.example.com/healthz # The UI loads over TLS$ curl -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 # The API sees the real client address, not the proxy's. Sign in, then look# at the most recent audit entry in the UI — the IP column should be yours.
The real test

Reference: Reverse Proxy for the full nginx and Caddy configurations, and Production Deployment for the rest of the hardening checklist.