However you front Beacon, the proxy has the same three jobs:
- Send
/api/*and/wsto beacon-server (port8080) and everything else to beacon-web./wsis a WebSocket, so the proxy must pass the upgrade through and keep idle sockets open for more than 90 seconds (the browser pings every 30 seconds; the server drops a socket after 90 seconds of silence). - Overwrite
X-Real-IPwith the connecting client's address and pass the originalHostheader. beacon-server ignoresX-Forwarded-ForandTrue-Client-IPentirely. - Be listed in beacon-server's
server.trusted_proxies(CIDR:/32for one IPv4 host,/128for IPv6). Only those peers may setX-Real-IP. Get this wrong and every visitor shares the proxy's rate limit and WebSocket connection cap.
VITE_API_BASE and VITE_WS_URL then point at the public paths, for example
https://beacon.example.com/api/v1 and wss://beacon.example.com/ws.
Caddy is the default in both Docker deployments:
Caddyfile.proxy. The
relevant part:
handle /api/* {
reverse_proxy app:8080 {
header_up X-Real-IP {remote_host}
}
}
handle /ws {
reverse_proxy app:8080 {
header_up X-Real-IP {remote_host}
}
}
handle {
reverse_proxy web:80
}Caddy handles the WebSocket upgrade and keeps the Host header by itself. It reaches the app
over the compose network, so data/app/config.yaml trusts that subnet:
server:
trusted_proxies: [172.30.0.0/24]Behind Cloudflare or another CDN, {remote_host} is the CDN, not the visitor. The comment at
the top of Caddyfile.proxy shows how to trust the CDN's ranges and forward {client_ip}
instead. The same idea applies to nginx and Apache: trust the CDN at the edge and pass its
validated client address, never a header the client could have set.
app_config/nginx/beacon.conf is a complete server block for
nginx on the host in front of the containers. The essentials:
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location = /ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade; # map is in the example file
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 120s;
}
location / {
proxy_pass http://127.0.0.1:8081; # beacon-web published on loopback
}nginx sends the upstream address as Host by default, which fails the WebSocket origin check,
so keep proxy_set_header Host $host. When nginx reaches the containers through published
ports, the app sees the Docker network's gateway as the peer, so trust the compose subnet
([172.30.0.0/24]). For a beacon-server running directly on the host, use
["127.0.0.1/32", "::1/128"].
Apache works the same way. app_config/apache/analyzer-vhost.snippet.conf
is a working virtual host snippet, including the RequestHeader set X-Real-IP line that
replaces whatever the client sent.
REST requests under /api/v1 are rate limited per client IP, 300 a minute by default. IPv6
clients share one budget per /64. A client over the limit gets 429 with rate_limited in the
body and a Retry-After header.
WebSocket has two separate limits. At most 5 open connections per IP: a connection over the cap
is accepted and then closed with code 1013 (try again later) before hello. At most 10
upgrade attempts per IP per minute: beyond that the handshake gets 429 with Retry-After: 60.
Both are set under websocket: in config.yaml.
Behind a reverse proxy all of these only work with server.trusted_proxies set, or every
visitor counts as the proxy's IP. Without it every visitor shares the proxy's budget and the
site gets 429s under normal load. Beacon logs a warning at startup when limits are on and no
proxy is trusted, and once at runtime when a request carries forwarding headers it is ignoring.
When the web app and the API are on different hosts, browsers may only open /ws from the
page's own host unless the web origin is listed in websocket.allowed_origins
(https://*.example.com covers every subdomain). REST CORS allows any origin by default.
Access logs, a manual block list and fail2ban jails for both Caddy and Apache are in
app_config/fail2ban/.