I built my own VPN without opening a single port

A Raspberry Pi 4 in a black aluminium heatsink case, with the Ubuntu and Tailscale logos over the photo

No static IP, no port forwarding, no DDNS. With a Raspberry Pi 4, Ubuntu Server 26.04 LTS and a Tailscale exit node, my home network is now reachable from outside without a single door open to the internet.

PC/PhoneCafe wi-fihome network · 192.168.3.0/24modemCGNATpi 4exit nodecoordination443/tcp · outboundinbound connection attemptno open port · no static ipno forwardingboth ends dial outon their ownendpoints are exchanged,keys are distributedWireGuard tunnel · end-to-end encrypted100.64.0.0/10 · direct · UDP
The whole point is in these three scenes. In a classic VPN setup the first one has to succeed; with Tailscale it is never even attempted.

Why I needed this

As a network engineer, “remote access” only ever meant one thing to me: something, somewhere, gets exposed. A rule on the firewall, a port on the modem, a static IP somewhere. Access was a hole you opened.

At home, none of the conditions that model needs were true. What I wanted was ordinary enough: a safe way out of public Wi-Fi, going online through my home IP while abroad, reaching the other devices at home. The need was simple. The classic way of building it kept hitting a wall.

The design I had in mind at first

My first design came purely out of habit, and it went like this:

  • A WireGuard server on the Pi
  • Port forwarding on the modem for UDP/51820
  • A DDNS record to deal with the dynamic IP
  • Peer configs handed out to clients by hand

On paper that design is correct. It’s the natural conclusion of network thinking: there’s a service listening somewhere, and you open a path to it.

The problem

This design cracked in three separate places.

1. There was no port to open behind the modem

My ISP had CGNAT. In the modem’s admin panel the WAN IP address sat in the private 10.x.x.x range, which means the modem was already behind a shared, private address. The port forwarding page looks like it works, but the port you open never reaches the internet.

The IPv4 tab of a modem's admin panel; the WAN IP address is in a private range starting with 10, the LAN IP address is 192.168.3.1

The classic way to confirm this is simple: compare the address the modem sees on its WAN side with the address you actually appear as from outside. At the same time, from a device at home:

curl ifconfig.me
# 5.47.x.x
Output of curl ifconfig.me in a terminal; a public IP address starting with 5.47, completely different from the modem's WAN IP

The two addresses don’t overlap at all. What the modem calls its “WAN IP” is really an address inside the ISP’s own network, and the address I actually reach the internet with is somewhere else entirely. That gap is direct evidence of at least one more NAT layer between the modem and the internet, the classic sign of CGNAT. This address is the public IP that mini (macOS) gets from its own ISP before touching the tailnet at all, and it is completely different from 78.177.x.x, the address pi (linux) shows up as on the direct connection later in this post: mini and pi aren’t on the same network, they reach the internet through two independent exits.

2. The IP wasn’t static

DDNS solves that, but it adds one more dependency: a blind spot between the moment the IP changes and the moment the DNS TTL runs out.

3. The part that really bothered me was the architecture

Even without CGNAT, the port I opened would have been a listener exposed to the entire internet. A port forwarding rule doesn’t look at identity, it only looks at the packet. You hand access control over to whatever authentication that service happens to ship with.

If you frame the problem as “how do I open a port”, the answer is always going to be a hole. The real question was this: why do I have to build the connection from the outside in?

What I tried and why I dropped it

ApproachWhy it didn’t work / why I dropped it
DDNS + port forwardNever got off the ground, because of CGNAT.
Static IP from the ISP

Extra cost, and not always offered on a residential plan. It also doesn’t solve the problem, it buys it.

VPS + WireGuard relay

It worked. But a monthly bill, another server to maintain, and a single point of failure.

Cloudflare Tunnel

Good for HTTP services. But what I wanted was full tunnel plus exit node behaviour.

Tailscale

The one I went with. Mesh instead of the classic hub-and-spoke VPN: no central server tunnelling the traffic, nodes connect straight to each other. Nothing to open on the modem.

What actually happens at the network level

Tailscale splits into two layers, and that split is familiar ground for a network engineer: control plane and data plane.

  • Control plane: the coordination server. It knows who is in the tailnet, which node holds which public key, and which candidate endpoints each one has. It doesn’t carry your traffic.
  • Data plane: WireGuard. Traffic flows directly between two nodes and is encrypted end to end.

Here is the critical part: every node reaches the coordination server from its own side, over an outbound connection. Outbound is the direction NAT already allows. So there is no port listening anywhere that has to be reachable from the outside.

So how do two sides behind NAT ever meet?

This is where NAT traversal comes in. The simplified flow:

  1. Each node discovers its own candidate endpoints: its local address, and how it looks from outside (a STUN-like discovery).
  2. Those candidates get passed to the other side through the coordination server.
  3. Both sides send a UDP packet to each other at the same time. Each NAT reads it as “an outbound connection my own user started” and opens the return path. This is hole punching.
  4. If the punch fails, traffic goes through a DERP relay server. The relay carries the encrypted traffic, it can’t read it; it’s only a courier.

I measured this on my own setup. Looking at pi (linux) from the mini (macOS) node, with pi selected as the exit node on the macOS side, tailscale ping pi comes back as a real round trip rather than a self-ping, and the tailscale status output shows the connection as direct. The traffic isn’t going through a DERP relay, it’s flowing straight between the two nodes, through both NATs:

Output of tailscale ping and tailscale status run from mini in a terminal; the pi node is connected as active; exit node; direct 78.177.x.x:6381, along with tx/rx traffic counters

The active; exit node; direct 78.177.x.x:6381 line is the proof: pi is being used as the exit node and the connection is direct, with no relay in between. A connection running over DERP would show relay here instead of direct.

CONTROL PLANE: identity, key distribution, endpoint exchange · carries no trafficcoordinationoutbound HTTPSDATA PLANE: WireGuard · end-to-end encrypted · direct when possibleclientpi 4directif not → DERP relay
The control plane never sees the traffic; the data plane knows nothing about identity. In a classic VPN both live in the same box, and that box has to be reachable from outside.

The final architecture, and what makes an exit node different

An exit node means a node in the tailnet advertises not just its own network but the default route. When a client picks that exit node, it sends all of its traffic there, and the node NATs it out over its own internet connection.

In network terms: inside the tailnet, the Pi becomes the next hop for 0.0.0.0/0 and ::/0.

Flip the switch below to compare the path the traffic takes, and the IP the outside world sees, with the exit node on and off.

PC/PhoneCafe wi-ficafe gatewayshared networkpi 4exit nodeinternettarget serviceunencrypted · local network sees trafficWireGuard tunnel · cafe networksees only encrypted UDPvisible source IP:cafe IPvisible source IP:home IP

Only the parts of the setup that matter

The step-by-step setup doc isn’t in this post, it’s in the repo. What I’m leaving here are the four settings that cost you hours when you don’t know about them.

Turning on IP forwarding

The Pi is going to act like a router. Ubuntu doesn’t do that by default.

# under sysctl.d so it survives a reboot
echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-tailscale.conf
echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf
sudo sysctl -p /etc/sysctl.d/99-tailscale.conf

Advertising it as an exit node

sudo tailscale set --advertise-exit-node
sudo tailscale up

Advertising isn’t enough, it has to be approved from the admin console.

UDP GRO and TCP BBR for throughput on the Pi 4

With default settings the Pi 4’s routing performance comes out lower than you’d expect. Two separate settings make a serious difference.

Coalescing the UDP segments it receives:

sudo ethtool -K eth0 rx-udp-gro-forwarding on rx-gro-list off

This one is lost on reboot; making it stick needs a systemd unit or a networkd-dispatcher hook.

On top of that, switching the TCP congestion control algorithm from Linux’s default cubic to Google’s bbr improved throughput noticeably as well. It’s meant to be used together with the fq (fair queue) qdisc:

# added to the same /etc/sysctl.d/99-tailscale.conf file
echo 'net.core.default_qdisc = fq' | sudo tee -a /etc/sysctl.d/99-tailscale.conf
echo 'net.ipv4.tcp_congestion_control = bbr' | sudo tee -a /etc/sysctl.d/99-tailscale.conf
sudo sysctl -p /etc/sysctl.d/99-tailscale.conf

Unlike GRO, this one already lives under sysctl.d, so it survives a reboot on its own. No extra unit or hook needed.

Key expiry

If an exit node’s key expires, the node drops off the tailnet, and you can’t sign it back in because you can’t reach it remotely any more. For nodes in a server role the usual choice is to turn key expiry off (Expiry disabled or set up an OAuth / auth key strategy).

What worked

  • Not a single rule changed on the modem. The port forwarding page stayed completely empty, and that was the most satisfying part of the whole thing.
  • The dynamic IP just isn’t a problem any more. Nothing anywhere is tied to an IP address.
  • Tried it on Android, iOS, Windows and macOS clients, no difference in behaviour.
  • Thanks to MagicDNS I reach machines by name instead of by IP.

Where it falls short

Upload bandwidth became the ceiling

Everything you download through the exit node has to come back out through your home connection’s upload speed. Most of the time the Pi 4’s capacity isn’t even the bottleneck; the home link’s upload fills up first.

Trade-offs

What I gainedWhat it cost me
No open portsA dependency on a third-party coordination service
No static IP neededMy identity provider is now part of my access path
Identity-based accessGetting the ACL right is on me
Going out through my home IPTraffic traceable back to my home IP: control, not anonymity
Low costOne Pi is one single point of failure

That row about the home IP deserves underlining: this setup is not a replacement for a commercial VPN. A commercial VPN exists to hide where you’re coming from; the point here is to push your traffic through an exit you trust. Those are two different problems.

What I’d do differently from the start

  • I’d write the ACLs on day one. Adding them later drops you straight into the “get it working first, tighten it later” trap.
  • I’d make the GRO setting persistent on day one too; some of my performance numbers came out wrong because it wasn’t.

What I learned

What this build actually taught me wasn’t a command, it was a change of frame.

Remote access turned out to be an identity problem, not a topology problem. Opening a port asks “is this packet coming from the right place”. What I wanted to ask was “is this device mine”.

I’d also spent years treating NAT as an obstacle to get around. But NAT traversal doesn’t break NAT’s rule, it uses it. The outbound connection was allowed all along; all that happens is both sides step out at the same moment. There’s still no door open anywhere! Both sides just walked out of their own, at the same time.