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
| 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 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_proxysets none of those headers; nginx’s$proxy_add_x_forwarded_forappends 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
/metricsguard, which answers loopback and RFC1918 addresses only
$proxy_add_x_forwarded_for appends to whatever the client sent, and the common configs floating around never clear True-Client-IP at all — which is the header chi consults first. A proxy that sets only X-Forwarded-For lets a caller pick the address it is rate-limited, allowlisted and audited under, and reach a metrics endpoint that believes it is on the loopback.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.
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.
# 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 frontendWhile you are there
# 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=laxNever 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
# 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.WS_BASE_URL and the frontend rebuild.Reference: Reverse Proxy for the full nginx and Caddy configurations, and Production Deployment for the rest of the hardening checklist.