Exposing Self-Hosted Streaming Behind CGNAT
A little over two years ago I left Vero (my ISP), where I had a working public IP, and moved to TIM Ultrafibra. The reason was simple: TIM was offering monstrous speeds, and I happened to have the hardware to handle them. On Vero I was perfectly happy with my 1 Gb, but I wanted TIM's 2.5 Gb — so I switched.
The unpleasant surprise was finding out TIM doesn't give you a public IP, and there was no room to negotiate either. After a string of completely failed attempts, I decided to find another way to expose a few streaming services.
Lucky for me I have a decent grasp of networking, which saved me a lot of grief. But I'd still need to learn more if I wanted the whole thing running properly.
When you're on CGNAT — the famous shared IP — the streaming experience (upload especially) tends to be pretty rough, for two main reasons:
- Badly configured MTU.
- UDP ports recycling at absurd speeds.
There are a few other causes out there, but those two are the ones that really make it miserable. It's also why so many people try to negotiate a public IP with their ISP, and the same reason FPS gamers get the headaches they do.
So here's what I tried, and what actually worked.
First attempts: Cloudflare and Tailscale
The first thing that came to mind was exposing the services through Cloudflare, using Tunnel, or through Tailscale. I never got around to testing Tailscale.
Cloudflare worked perfectly well — fast, robust, with an excellent keep-alive for dealing with UDP's abrupt changes. But it had a problem, and it was the same one Tailscale had (which is why I didn't bother trying it): under both companies' policies, you can't serve streaming. If you wanted to, you'd have to use their servers, meaning you'd pay for it. The risk is high — they can ban you from the service with no real explanation.
I've got a bunch of services running through Cloudflare and I couldn't take that risk. Moving to Tailscale Funnel was off the table for the same reason. And I didn't want to keep a closed WireGuard-style network where only I had access: my cousins and relatives aren't exactly tech people, and that would cost me setup time. What I wanted was to hand them a domain and have them just open it.
So what now?
Pangolin: good, but only good
Then I remembered Pangolin. Maybe if I grabbed a dirt-cheap VPS and ran Pangolin on it, I could "emulate" what Cloudflare Tunnel offered at a similar level.
The Pangolin container image looked perfect: it runs on Docker and ships with Traefik, Gerbil, and CrowdSec out of the box. Just spin it up, configure the domains, install Newt on the local machines, and done. I'd be streaming from home, behind CGNAT and exposed to the world. Problem solved — or so I thought.
I left it running for about two months, but the sluggishness was getting on my nerves. I hadn't dug into the real causes yet, until I finally had no choice. The setup was good — but only good. And I wanted excellent.
My first move was basic testing: checking routes, DNS, MTU, and so on. Funny enough, even though that wasn't the heart of the problem, I fixed a few unrelated issues along the way. So it did have some minor influence.
But cleaner logs and more precise tests showed what I'd already suspected: UDP and ping/keep-alive. UDP was recycling extremely aggressively, on very short intervals, and for streaming that's pure hell. Picture trying to load video, the player, range requests, a chat WebSocket... all of that needs to flow smoothly.
A new problem that would take some creativity to solve. The decision was to stop trying to make a UDP tunnel behave on an aggressive CGNAT and swap the core of the architecture for a persistent TCP tunnel. But how? I had no idea. So I left it on standby. I didn't have time to mess with my streaming anyway, and it was already up — even if in a less-than-ideal state.
A few weeks went by.
Stumbling onto FRP
I was on the Unraid dashboard, browsing the apps in the store. Then, completely by chance, something caught my eye: FRP. Honestly, I'd never used it, and to me it was just another proxy service like any other. I opened the image out of pure curiosity — you know when you look at something but don't actually see (read) it? I wasn't even thinking about my streaming anymore, and one thing grabbed me: the "F" at the start of FRP, which stands for Fast.
That made me more curious. Why would this one be different? Why fast?
After reading up a bit, the explanation was simple.
FRP (Fast Reverse Proxy) was built specifically to expose local services sitting behind NAT/firewall, using a public server as a relay. The architecture is:
frps= the FRP server on the VPS with a public IP.frpc= the FRP client on the local machine, behind NAT/CGNAT.
The local machine opens an outbound connection to the VPS. The VPS then exposes public ports (temporary or internal), which Traefik later uses as HTTP backends.
Pangolin (Newt/Gerbil) uses a more complete architecture: Traefik + identity + WireGuard/Gerbil/Newt tunnels. It's great as a "platform," but it adds more moving parts. Pangolin's own docs describe Gerbil as the WireGuard tunnel manager and Newt as the edge client.
FRP was made for exactly this — exposing a local service behind NAT/firewall through a public relay server — and it supports TCP, UDP, HTTP, and HTTPS.
In my case, that was the advantage. The two setups boiled down to:
- FRP = a persistent TCP connection going out from the local network → public VPS → Traefik.
- Pangolin = a more sophisticated tunnel/overlay → more dependent on how the tunnel behaves.
The FRP route was better not because FRP is "superior" to Pangolin across the board, but because it solves the actual bottleneck: it stops you from fighting UDP on a bad CGNAT.
The final architecture
External user
↓ HTTPS
Traefik on the VPS (Pangolin)
↓ internal HTTP to the FRP port
frps on the VPS (Pangolin)
↓ persistent TCP tunnel
frpc on the local machine
↓
Local media service
On the VPS that already had Pangolin, I basically installed and configured frps (the server) along with a token. Each machine that would act as a client got frpc (the client) with the server's token.
The config was actually simple: bind the IP/port, set the token, validate, and start it.
There was one catch, though: I had to do something slightly different with the Traefik already running on the server. Instead of recreating the resources through the Pangolin UI, I used the dynamic File Provider. The result was striking — the practical gain was immediate.
Before, access through Newt/Pangolin suffered from high TTFB and instability caused by the CGNAT. After switching to FRP, the services started responding instantly and with rock-solid stability.
And yes. It's working.
The change worked because the problem was never just "slow proxy." It was architecture.
Cloudflare Tunnel was fast, but unsuitable as the main solution for heavy self-hosted streaming if you stay within the proper policies. Newt/Pangolin was elegant for exposing private services, but the aggressive CGNAT was wrecking the stability of the UDP path. FRP solved the exact pain point: it created a persistent TCP tunnel between the local machines and the public VPS.
Traefik kept doing what it does well: terminate HTTPS, apply rules per domain, and forward to the internal backends. FRP took over the transport. Each tool ended up in its proper role.
I'll put together a short tutorial soon on how this was set up and how you can do it on your own network.
See you around.
Member discussion