An SFU on a PaaS: the two-IPs problem
Castalong's media plane is a LiveKit SFU, and an SFU is about the least PaaS-shaped workload there is: it wants raw UDP from the public internet, on a stable address, with no proxy in the path. A developer PaaS wants to give you an HTTP load balancer. Making the two coexist was mostly straightforward — the platform (Zerops) supports direct public ports on runtime services — but three findings cost real hours, and all three generalise to running any WebRTC server behind someone else's network edge.
STUN tells you the wrong address
The setup: a dedicated public IPv4 for inbound media, direct ports for ICE (one TCP, one muxed UDP — the platform exposes individual ports, not ranges, so the classic 10,000-port UDP range collapses to a single muxed port). LiveKit's use_external_ip: true then discovers the server's public address via STUN — and discovers the wrong one. Outbound traffic from the container leaves through the platform's shared NAT egress; STUN faithfully reports that shared address. Inbound media, meanwhile, arrives on the dedicated IP. The result is an SFU that cheerfully advertises ICE candidates on an address where nothing ever arrives, and every session fails at connectivity checks while all the individual pieces look healthy.
On any platform where egress NAT and inbound dedicated IPs differ — which is most of them — autodiscovery is the wrong tool. Pin it: node_ip: <dedicated IP>, use_external_ip: false.
The 502 that impersonates your app
Signalling is ordinary HTTPS/WSS, so it rides the platform's L7 balancer. Each HTTP port of a service gets an auto-generated preview hostname — and the naming embeds the port for anything that isn't :80. We spent hours debugging a "broken" SFU that returned 502 on its preview URL, while the process was demonstrably serving. The URL was derived by pattern-matching another service's hostname; the real one carried a -7880 suffix. The balancer answers an unbound hostname with the same 502 it uses for a dead upstream, with a hint ("check your application is running on the correct port") that points squarely at your app. Read assigned URLs from the platform's API; never derive them.
Two smaller edge notes from the same territory: a service that binds fixed ports can't roll-deploy — the old container still holds the port, the new one gets terminated, and the service sits "active" with nothing listening, so deploys must stop-then-start. And inside the containers, binding both :: and 0.0.0.0 crash-loops — :: already accepts IPv4; bind it alone.
The TURN decision: deliberately absent
The home-lab deployment this replaced ran TURN/TLS on 5349 with a certificate that had no renewal path — a time bomb dressed as a feature. On the PaaS we chose not to rebuild it, and the reasoning is worth writing down. Guests behind UDP-blocking networks fall back to ICE over TCP on the direct TCP port, which browsers do automatically. TURN/TLS only adds value for networks that also block that — and those networks almost always allow only 443. The platform reserves 443 for its balancer, so a platform-hosted TURN could never sit on the one port that matters. Anyone who can reach 5349 can reach the ICE/TCP port; the added reach rounds to zero, while the cost is a cert-issuance pipeline for a non-HTTP service. If a real guest ever fails, the answer is a $3 VPS running coturn on 443 — not TURN on the PaaS.
One caveat learned while verifying this: don't "prove" the TCP fallback with a Go-SDK client. The Go SDK doesn't attempt ICE/TCP at all; a UDP-blocked lk room join fails outright. Browsers — the clients that matter here — do fall back. Test with the client class your users actually run.