The universal 60s idle-timeout footgun stated up front; the five-point contract any proxy must honor (incl. RUTSTER_TRUSTED_PROXIES + honest X-Forwarded-* for signature validation); complete snippets: nginx (explicitly not-recommended, snippet-only support), HAProxy (timeout tunnel in defaults per #2280; hard-stop-after warning), Traefik (pin the exact version — #10601 / v2.11.2 / #11405 WS-breaking patch history). Signed-off-by: Aaron D. Lee <himself@adlee.work>
5.3 KiB
Bring your own reverse proxy — tuned-timeout configs
Supported: disable the caddy service in the compose stack
(quickstart-docker.md); the engine keeps its
plaintext :8080 listener. Everything below terminates TLS in your proxy and forwards to
the engine.
The universal 60-second footgun
nginx, HAProxy, and Traefik all default their idle/read timeouts to ~60–180 seconds — every
one of them will kill a quiet, perfectly healthy call WebSocket. A Media Streams WS can
legitimately be near-silent in one direction for hours, and neither Twilio nor Telnyx sends
protocol-level keepalives. The engine ships app-level WS pings (RUTSTER_WS_PING_SECS,
default 20) as belt-and-braces, but your proxy timeouts must still exceed the maximum call
duration. Every snippet below does that.
The contract any proxy must honor
- Publicly-trusted cert, auto-renewed — no self-signed path exists (certificates.md).
- WS upgrade with no frame buffering and no connection-lifetime cap — 20 ms audio frames, calls up to 24 h.
- Honest
X-Forwarded-ProtoandX-Forwarded-Host(including on the WS upgrade request):X-Twilio-Signatureis HMAC over the URL as Twilio saw it (Twilio's SSL-termination guidance). - Engine-side trust: set
RUTSTER_TRUSTED_PROXIESto your proxy's source IP/CIDR — forwarded headers from unlisted sources are ignored, and signature validation will fail. - TLS 1.2+1.3, mainstream ECDHE — never 1.3-only (Twilio's 1.3 client support is undocumented).
nginx (works if you insist — not a recommended edge)
Why not recommended: no native DNS-01/wildcard support, and cert renewal requires a reload whose old workers linger against hours-long WS connections (research basis: TLS brief §4). rutster ships this snippet and nothing more for nginx.
map $http_upgrade $connection_upgrade { default upgrade; '' close; }
server {
listen 443 ssl;
server_name pbx.example.com;
ssl_certificate /etc/letsencrypt/live/pbx.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/pbx.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3; # never 1.3-only
location / {
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;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_buffering off; # 20 ms frames must not be coalesced
proxy_read_timeout 86400s; # the 60s default is the footgun
proxy_send_timeout 86400s;
}
}
HAProxy (3.2 LTS or newer)
The best WS mental model of the classic family: after the upgrade, only timeout tunnel
governs the connection — but only if you set it; put it in defaults or it silently
doesn't apply to the paths you think it does (see
haproxy#2280). Two extra notes: acme.sh
can hot-load renewed certs through the stats socket with zero reload; and hard-stop-after
kills calls that are still draining — leave it unset or above your drain deadline.
defaults
mode http
timeout connect 5s
timeout client 75s
timeout server 75s
timeout tunnel 24h # governs upgraded WS; unset = the 60s-class footgun
frontend fe_rutster
bind :443 ssl crt /etc/haproxy/certs/ ssl-min-ver TLSv1.2
http-request set-header X-Forwarded-Proto https
http-request set-header X-Forwarded-Host %[req.hdr(Host)]
default_backend be_rutster
backend be_rutster
server fob 127.0.0.1:8080
Traefik v3 — PIN THE EXACT VERSION
Traefik has the best built-in DNS-01/wildcard of the classic family and a track record of breaking WebSockets in patch releases: #10601, the v2.11.2 timeout-behavior flip, and #11405. Pin the exact image tag and never auto-update the edge.
# traefik.yml (static config)
entryPoints:
websecure:
address: ":443"
transport:
respondingTimeouts:
readTimeout: 0 # 0 disables; the 60s-class default kills quiet WS
idleTimeout: 0
writeTimeout: 0
certificatesResolvers:
le:
acme:
email: you@example.com
storage: /data/acme.json # persist this volume — certificates.md
dnsChallenge:
provider: cloudflare
# dynamic config
http:
routers:
rutster:
rule: "Host(`pbx.example.com`)"
entryPoints: [websecure]
service: rutster
tls: { certResolver: le }
services:
rutster:
loadBalancer:
servers:
- url: "http://127.0.0.1:8080"
Traefik forwards X-Forwarded-Proto/Host by default — you still must list its address in
RUTSTER_TRUSTED_PROXIES.
First call
Any of the above in front of the engine, then the same two first-call paths as
quickstart-docker.md: browser at https://pbx.example.com/, Twilio
webhook at https://pbx.example.com/v1/trunk/webhook.