< the skull

EXPOSING SELF-HOSTED STREAMING BEHIND CGNAT
===========================================
2026-06-15

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.


< the skull