Shipping to the Living Room: When the Hard Part Is the Internet

Deploying our GPU video app to a teammate's home box took an afternoon. Getting it onto the public internet — past CGNAT, two modems fighting over one IP, and a Viettel router with a genuine firmware bug — took the rest of the day.

The Easy 10%

We run a GPU-heavy video/voice app — the kind of thing that wants an NVIDIA card, ffmpeg with NVENC, a couple of Python venvs full of torch, MongoDB, and a Next.js frontend. Normally it lives on machines we control. This time it needed to run on a teammate's home PC: an RTX 3060, 32 GB RAM, sitting in their living room on a residential fibre line.

The application deploy was the boring part, and boring is good. Node via nvm, pm2, Python 3.12 via uv (the box shipped 3.14, which is too new for the torch wheels — first surprise), a Mongo container, build the frontend, register the pm2 services. A couple of hours of well-trodden path. Services green, GPU detected, NVENC encoding at full tilt after patching the consumer-card session cap.

Then came the actual job: make it reachable at https://<its-subdomain> from the outside. That's where the afternoon went to die.


Problem 1: You Don't Have a Public IP

First instinct: point the DNS record at the home connection's public IP, forward ports 80/443 on the router, run certbot. Textbook.

Except port 80 was dead from the outside. Not "firewalled" dead — unreachable dead. A traceroute from the box told the story in one line:

1  192.168.1.1        (the home router)
2  10.137.0.2         (…a private address?)
3  10.255.220.x

Hop two is a 10.x carrier-private address. That's the fingerprint of CGNAT — carrier-grade NAT. The "public IP" the box thinks it has is shared across a whole pool of Viettel customers, and inbound connections to it never reach this house. No port-forward on Earth fixes that; the forwarding has to happen on hardware you don't own.

Lesson one, learned the hard way: before you plan any port-forward, check hop two of a traceroute. If it's 10.x or 100.64.x, stop — you're behind CGNAT, and your options are a tunnel (Cloudflare Tunnel, Tailscale Funnel) or calling the ISP for a real IP.


Problem 2: Two Modems, One IP

Mid-deploy, the teammate swapped internet carriers to get off CGNAT. Good news: the new line was not CGNAT (hop two was a real public Viettel address). Bad news: the box now had two modems plugged in — WiFi to the old one, ethernet to the new one — and, this being the consumer-router universe, both lived at 192.168.1.1.

A Linux box with two interfaces on the same subnet, same gateway IP is a special kind of confused. Which 192.168.1.1 do you mean? The kernel genuinely doesn't know.

The trick that saved me: interface-bound requests.

curl --interface enp3s0 https://192.168.1.1   # the new (ethernet) modem
curl --interface wlp4s0 https://192.168.1.1   # the old (WiFi) modem

--interface forces the socket out a specific NIC (SO_BINDTODEVICE), so you can talk to each modem individually even though they share an address. That let me confirm which line was CGNAT (the WiFi one) and which could actually serve traffic (ethernet). Then the real fix was almost embarrassingly simple: turn the useless modem off. One WAN, symmetric routing, no policy-routing gymnastics. Fewer moving parts beats clever routing every time.

Oh, and one more: after the carrier swap, the ethernet interface had quietly come up with ipv4.method: link-local in NetworkManager — so it grabbed a 169.254.x address and never asked for DHCP. It looked connected (link light on!) but had no IPv4 at all. nmcli con mod <conn> ipv4.method auto and it leased instantly. "Link is up" and "you have internet" are different sentences.


Problem 3: The Router Has a Bug (Two, Actually)

Now I could reach the router's admin page — sort of. First attempt was over an SSH tunnel:

ssh -L 8443:192.168.1.1:443 box   # ❌ router's SPA 404s every XHR

The router's web UI is a single-page app, and a plain -L forward rewrites the Host header to localhost:8443. The SPA's API calls didn't match, so everything 404'd. The fix is a SOCKS proxy instead:

ssh -D 1080 box
# then point a browser at socks5://localhost:1080 → https://192.168.1.1

SOCKS preserves the real host and the real TLS cert — the browser genuinely thinks it's on the LAN. If you ever need to reach a home router remotely, reach for -D, not -L.

With that working, I hit the router's own firmware quirks:

  1. It refuses to forward a WAN port that equals a management port. Its admin lived on 80/443, so it wouldn't let me forward 80/443 to the box. Fine — move the admin ports.
  2. Changing the admin port bricks the admin UI until you power-cycle the router. Change HTTP/HTTPS admin to 81/444, hit apply, and the web UI vanishes from every port. A full nmap of the router found only DNS alive. A reboot brought it back on the new ports. (Every consumer-router owner has met this bug; few have named it.)

Once the admin ports were out of the way, 80/443 forwarded cleanly to the box, and — because the line was no longer CGNAT — they were finally open from the outside. certbot --nginx grabbed a Let's Encrypt cert (it validates against fresh authoritative DNS, so it worked even before my laptop's DNS cache caught up), and the app was live over real HTTPS.


Bonus Boss: The Remote Desktop

Somewhere in the middle I needed a desktop on the box to poke at the router natively. This turned into its own mini-saga:

  • gnome-remote-desktop (the built-in) kept dropping the RDP session with an NLA MIC verification failed during its headless "handover" — a known incompatibility with the Mac RDP client.
  • Switched to xrdp. Black screen. GNOME 50's session is fully systemd-managed (gnome-session@…​.target) and simply won't come up inside an xrdp session.
  • Installed XFCE as the xrdp session instead → instant, reliable desktop. (Also: dbus-x11 was missing, which made the window manager exit 127; and snap Firefox refuses to launch when spawned from outside a "real" desktop session, so I dropped in a non-snap browser.)

The recurring theme: the newest, most-integrated option is the most fragile over remote/headless plumbing. XFCE, --interface binds, single-WAN, a plain SOCKS proxy — the boring primitives all just worked.


The Lesson

The application was maybe 10% of the effort. The other 90% was the last mile — a residential connection that was never designed to host anything, mediated by a modem whose firmware actively fights you.

Cloud deployment conditions you to forget that "the internet" is a series of very specific, very physical hops owned by other people. Put a server in a living room and every one of those hops introduces itself personally: the carrier that NATs you, the modem that lies about your IP, the router that bricks its own admin panel.

If I did it again from zero, I'd reach for a Cloudflare Tunnel on day one and skip the entire port-forward circus — no public IP required, immune to CGNAT, and it shrugs off the dynamic-IP problem too. Sometimes the right move isn't to win the fight with the network. It's to refuse to have it.