[ Reaching a Network Behind Starlink with WireGuard ]

[ Overview ]

Starlink gets you online almost anywhere with a view of the sky. Getting
back in from the outside is the hard part. A residential Starlink sits
behind carrier-grade NAT, so there's no public IPv4 address to forward a port
to, and Starlink's own router has no port forwarding anyway.

Starlink is my backup line at home, and I carry a Starlink Mini when I travel.
At home the Starlink router still sits in front of my firewall, so it's
double NAT on top of CGNAT. When a primary link drops, anything that waits
for an incoming connection stops working.

The fix is to turn the connection around. Nothing has to reach in if the
site behind Starlink dials out. A small VPS with a real public IP sits
in the middle, the site keeps a WireGuard tunnel open to it, my laptop and
phone connect to the same VPS, and the VPS routes between them:
The hub A VPS with a public IP at the top. A laptop or phone at the bottom left and the site behind Starlink at the bottom right, inside a dashed CGNAT boundary, both have solid arrows pointing up to the VPS: each one dials out. A dashed line runs from the laptop up through the VPS and down to the site: that's the path your traffic takes. VPS (the hub) 10.99.0.1 203.0.113.10:51820 laptop / phone anywhere 10.99.0.10 behind CGNAT and NAT site behind Starlink router 10.99.0.2 LAN 192.168.50.0/24 dials out dials out, keepalive 25 s your traffic, relayed by the hub
Both ends dial out to the hub, so nothing ever has to reach in. Your traffic rides the two tunnels: client to VPS, then VPS to the site.
Both arrows point at the VPS. Nothing ever connects to the Starlink side, so
CGNAT, double NAT and a public address that keeps changing stop mattering.
And because the site is the one dialing, the tunnel comes back by itself
after an outage, over whichever internet line is up at the time.

Every address in this guide is an example. 203.0.113.10 is a documentation
address: use your VPS's real IP, and pick your own subnets.

[ Why port forwarding fails ]

Check what you have first. Compare the WAN address your router shows with
the address the internet sees:

  curl -4 ifconfig.me

If the router's WAN address falls in 100.64.0.0/10 (100.64.x.x through
100.127.x.x), you're behind carrier-grade NAT. That range is set aside for
it in RFC 6598. Your public address belongs to Starlink's equipment and is
shared with other customers, so nothing you forward at home will ever see an
incoming connection.

Bypass mode doesn't change that. It only switches off the Starlink router's
own NAT, so your router's WAN port gets the 100.64.x.x address directly.
Starlink's CGNAT is still upstream, and inbound IPv4 still stops there. The
Public IP option on the Priority plans (below) is the only IPv4 way past it.
IPv6 doesn't go through it at all.

A traceroute shows it too: a few hops in, you'll pass an address like
100.64.0.1, Starlink's CGNAT. The hops after it show where your traffic
leaves Starlink's network (the reverse DNS names usually include the city),
and that can be a long way from where your public address geolocates. That
matters later, when you pick where the VPS goes.
Why nothing gets in Nested boxes: Starlink's carrier-grade NAT around the Starlink router, around your own router, around the LAN. An incoming connection from the internet stops at the outer CGNAT layer, because there's no port of yours to forward. A connection from the LAN going out passes through every layer. you, out on the internet Starlink CGNAT one public IP, shared with other customers Starlink router: NAT again WAN 100.64.x.x, LAN 192.168.1.0/24 your router (if you run one): NAT again LAN 192.168.50.0/24 incoming: nowhere to go outgoing works: the tunnel dials out
Every box is another layer of NAT. A connection from outside dies at the first one, Starlink's CGNAT, because the public address isn't yours. A connection going out from the LAN gets through all of them, and that's the direction the tunnel uses.

[ Your options ]

  A public IPv4      Starlink offers one only on Local and Global Priority
                     plans: you switch the IP policy to Public IP in your
                     account. It survives reboots, but it can change if
                     you move the dish or after a software update.
                     (Starlink's article)
  IPv6               Starlink hands out IPv6 too, and IPv6 has no NAT. It
                     needs your own router in bypass mode, and it only
                     works from networks that have IPv6. More below.
  A VPS hub          works on any plan, from any network, through any
                     amount of NAT. This guide.
  A mesh VPN         Tailscale, Headscale, NetBird or ZeroTier. They punch
                     through NAT for you and fall back to a relay when
                     they can't.

This guide uses the VPS hub. It needs nothing from Starlink's side, and plain
WireGuard runs on almost every router, computer and phone.

[ What you need ]

  A VPS        any provider's smallest Linux plan. It only moves packets
               for a few devices. It needs a public IPv4 address that
               doesn't change.
  The site     something behind Starlink that runs WireGuard and stays
               on: your own router (OPNsense, OpenWrt, MikroTik and most
               others can), or a Raspberry Pi on the LAN.
  Clients      the WireGuard app on phones, Windows and macOS, or
               wireguard-tools on Linux.

Pick your subnets now, and pick ones that won't clash with networks you'll
join later. If the site's LAN is 192.168.1.0/24 (the Starlink router's
default) or 192.168.0.0/24, sooner or later you'll sit on hotel or a
friend's Wi-Fi using the same range, and your tunnel loses. This guide uses:

  10.99.0.0/24       the tunnel: VPS .1, site .2, clients from .10 up
  192.168.50.0/24    the LAN behind Starlink

[ Step 1: the VPS ]

On a fresh Ubuntu or Debian VPS, everything in this step runs as root:

  sudo -i
  apt update && apt install wireguard ufw qrencode

Every device gets its own key pair: the VPS, the site, and each laptop and
phone. Each peer also gets a preshared key, an extra symmetric key on top of
the normal handshake. I make them all here in one folder only root can read,
because it's easier to follow. Generating each key on its own device and
copying only the public half over is better, and the WireGuard apps can do
that.

  umask 077
  mkdir -p /etc/wireguard/keys && cd /etc/wireguard/keys
  for n in vps site laptop phone; do
    wg genkey | tee $n.key | wg pubkey > $n.pub
  done
  for n in site laptop phone; do wg genpsk > $n.psk; done

In the configs below, (vps.key) means "paste the contents of that file".
Here's /etc/wireguard/wg0.conf on the VPS:

  [Interface]
  Address = 10.99.0.1/24
  ListenPort = 51820
  PrivateKey = (vps.key)

  # the site behind Starlink
  [Peer]
  PublicKey = (site.pub)
  PresharedKey = (site.psk)
  AllowedIPs = 10.99.0.2/32, 192.168.50.0/24

  # laptop
  [Peer]
  PublicKey = (laptop.pub)
  PresharedKey = (laptop.psk)
  AllowedIPs = 10.99.0.10/32

  # phone
  [Peer]
  PublicKey = (phone.pub)
  PresharedKey = (phone.psk)
  AllowedIPs = 10.99.0.11/32

AllowedIPs is the routing table. On the VPS it says which addresses live
behind each peer: the site gets its tunnel address plus the whole LAN behind
it, and each client gets only its own address. A packet for 192.168.50.20
goes to the site, and a packet for 10.99.0.10 goes to the laptop.
AllowedIPs is the routing table A packet from the laptop to 192.168.50.20. The laptop's AllowedIPs include 192.168.50.0/24, so it goes into the tunnel to the VPS. The VPS's peer list has a row for the site with 10.99.0.2/32 and 192.168.50.0/24, highlighted because the destination falls inside it, so the VPS sends the packet to the site, which delivers it to 192.168.50.20 on its LAN. a packet from the laptop to 192.168.50.20 laptop AllowedIPs: 10.99.0.0/24 192.168.50.0/24 matches, so into the tunnel VPS: who gets what site 10.99.0.2/32 192.168.50.0/24 laptop 10.99.0.10/32 phone 10.99.0.11/32 site 10.99.0.2 hands it to its LAN 192.168.50.20 on the LAN 192.168.50.20 is inside the site's 192.168.50.0/24, so the VPS sends it to the site. Each client only gets its own address back.
The VPS sends each packet to the peer whose AllowedIPs contain its destination. 192.168.50.20 falls in the site's 192.168.50.0/24, so it goes to the site. On a Raspberry Pi, the Pi then rewrites the source to its own LAN address (the masquerade rule) so the reply finds its way back.
Turn on forwarding, open the port and start it:

  echo 'net.ipv4.ip_forward=1' > /etc/sysctl.d/99-wireguard.conf
  sysctl --system
  ufw allow OpenSSH
  ufw allow 51820/udp
  ufw route allow in on wg0 out on wg0
  ufw enable
  systemctl enable --now wg-quick@wg0

Allow SSH before you enable ufw, or you lock yourself out. If your provider
also has a firewall in its control panel, open UDP 51820 there too.

The route rule is the only forwarding the VPS does: from the tunnel back
into the tunnel. Nothing from the tunnel goes out to the internet, so the
hub can't turn into a free exit for anyone holding a key. Without ufw, and
with the FORWARD policy set to drop, the same rule goes in [Interface]:

  PostUp = iptables -A FORWARD -i wg0 -o wg0 -j ACCEPT
  PostDown = iptables -D FORWARD -i wg0 -o wg0 -j ACCEPT

[ Step 2: the site behind Starlink ]

This is the end that dials out. On a Linux router, or a Pi with WireGuard
installed, /etc/wireguard/wg0.conf:

  [Interface]
  Address = 10.99.0.2/24
  PrivateKey = (site.key)

  [Peer]
  PublicKey = (vps.pub)
  PresharedKey = (site.psk)
  Endpoint = 203.0.113.10:51820
  AllowedIPs = 10.99.0.0/24
  PersistentKeepalive = 25

PersistentKeepalive is the line that makes the whole thing work. CGNAT
forgets a UDP mapping after a short time with no traffic. A keepalive every
25 seconds keeps the path from the VPS back to the site open, so the VPS can
reach the site whenever a client needs it. 25 seconds is the interval the
WireGuard docs suggest for getting through NAT.
Why PersistentKeepalive matters Two timelines from 0 to 100 seconds. Without keepalive, the site's handshake at 0 opens a path through CGNAT that fades out, and when a client connects later the hub's packet is dropped. With a keepalive packet every 25 seconds, the path stays open the whole time and the hub's packet gets through. no keepalive the path goes stale CGNAT forgets the path keepalive 25 s the path stays open a client connects a client connects 0 s 25 s 50 s 75 s 100 s dots = site packets
CGNAT drops a UDP path that's been quiet for a while; how long varies by carrier. A small packet every 25 seconds keeps it open, so the hub can always reach the site.
There's no ListenPort. Nothing ever connects to this end, so WireGuard can
pick any port it likes. Start it:

  sudo systemctl enable --now wg-quick@wg0
  sudo wg show

Within a few seconds, wg show should list a handshake with the VPS.

The LAN also needs a way back to 10.99.0.0/24. Which way depends on where
WireGuard runs:

  On the router        nothing more for routing. LAN devices already
                       send everything to the router, and the router knows
                       10.99.0.0/24 is down the tunnel. Allow wg0 to the
                       LAN in its firewall.
  On a Pi              the LAN's devices don't know where 10.99.0.0/24
                       is, and Starlink's router can't add a static route.
                       So the Pi masquerades: tunnel traffic reaches the
                       LAN from the Pi's own address.

For the Pi, turn on forwarding:

  echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-wireguard.conf
  sudo sysctl --system

and add NAT to its wg0.conf [Interface]. The Pi's only other network is the
LAN, so I don't name an interface:

  PostUp = iptables -t nat -A POSTROUTING -s 10.99.0.0/24 -j MASQUERADE
  PostDown = iptables -t nat -D POSTROUTING -s 10.99.0.0/24 -j MASQUERADE

If the Pi runs its own firewall, let it forward from wg0 to the LAN.

[ On OPNsense ]

If your firewall runs OPNsense, here's the same site config in its web UI,
under VPN, WireGuard:

  1. Peers: add the VPS. Its public key, the preshared key, Allowed IPs
     10.99.0.0/24, Endpoint address 203.0.113.10, Endpoint port 51820,
     Keepalive interval 25.
  2. Instances: add one. Let it generate a key pair, leave Listen port
     empty, set Tunnel address 10.99.0.2/24, and pick the VPS peer. Its
     public key goes in the VPS's site [Peer], in place of site.pub.
  3. Turn WireGuard on and apply.
  4. Interfaces, Assignments: assign the new wg device and enable it. Leave
     the IP settings empty; the instance already has them.
  5. Firewall, Rules, on that new interface: pass from 10.99.0.0/24 to
     whatever on the LAN should be reachable. Start narrow.
  6. VPN, WireGuard, Status: the VPS peer should show a recent handshake.

The instance adds a route for each peer's Allowed IPs, so the LAN reaches
10.99.0.0/24 with no extra setup. If your OPNsense sits behind Starlink's
router instead of taking over in bypass mode, nothing here changes. It only
dials out, and double NAT doesn't care.

[ Step 3: laptops and phones ]

The laptop's config, laptop.conf:

  [Interface]
  Address = 10.99.0.10/32
  PrivateKey = (laptop.key)

  [Peer]
  PublicKey = (vps.pub)
  PresharedKey = (laptop.psk)
  Endpoint = 203.0.113.10:51820
  AllowedIPs = 10.99.0.0/24, 192.168.50.0/24

That's a split tunnel. Only the tunnel and the site's LAN go through
WireGuard; everything else uses whatever network you're on.
A split tunnel A laptop on any network. Traffic for 10.99.0.0/24 and 192.168.50.0/24 goes through WireGuard to the VPS hub and on to the site's LAN. Everything else goes straight out the local network to the internet. laptop on any network VPS hub 10.99.0.1 site's LAN 192.168.50.0/24 10.99.0.0/24 and 192.168.50.0/24: through WireGuard everything else: straight out the local network
Only the tunnel subnet and the site's LAN go through WireGuard. Everything else uses whatever network you're on, so if the site goes down, the rest of your internet doesn't go with it.
The phone gets the same file with phone.key, phone.psk and 10.99.0.11.
To load it, show it as a QR code on the VPS and scan it with the WireGuard
app:

  qrencode -t ansiutf8 < phone.conf

Then delete phone.conf and phone.key from the VPS. The phone has its copy.

I leave DNS out of the client config. If you point it at a resolver on the
site, every lookup the phone makes depends on the Starlink end being up, and
the phone loses DNS whenever the site does. I use IP addresses for the
handful of things I reach over the tunnel.

[ Step 4: test it ]

Test from a network that isn't the site's: a phone on cellular with Wi-Fi
off is easiest. From inside the site's own LAN, everything works whether the
tunnel does or not, and that tells you nothing.

  On the VPS     wg show                 each peer's latest handshake
                                          should be under 2 minutes old
  On a client    ping 10.99.0.1          the VPS
                 ping 10.99.0.2          the site, through the hub
                 ping 192.168.50.1       the site's LAN

If the first ping works and the second doesn't, the problem is between the
VPS and the site. When it doesn't work goes through the usual causes.

[ The IPv6 route ]

IPv6 has no NAT, so in theory you skip the VPS and point clients straight
at the site. Starlink hands an IPv6 prefix (usually a /56, though some
people report smaller ones) to a router that asks for one. In practice there
are catches:

  - Without bypass mode, the Starlink router still hands IPv6 to a router
    behind it, so outgoing IPv6 works either way. Incoming doesn't: it
    drops unsolicited IPv6 before it reaches your router, and it has no
    settings to open ports. Bypass mode takes it out of the path, so your
    own router's firewall decides.
  - The prefix can change. Starlink says moving the dish or a software
    update can change your addresses, so you need dynamic DNS keeping an
    AAAA record current.
  - The client's network has to have IPv6 too. The big US cell carriers
    do; plenty of hotel, office and cafe Wi-Fi doesn't.

If you want it anyway, the site's router becomes an ordinary WireGuard
server: it gets a ListenPort and a [Peer] for each client, and its firewall
allows that UDP port to the router's own IPv6 address on the WAN side.
Clients point at the AAAA name. I keep the VPS as the path that always
works.

[ Lock it down ]

  - The VPS sees your traffic. Each hop is encrypted, but the hub
    decrypts and re-encrypts in the middle. Treat it as part of your
    network: SSH keys only, updates on, nothing else listening. Anything
    sensitive should still use SSH or HTTPS inside the tunnel.
  - Port scans see nothing. WireGuard doesn't answer packets that
    don't carry a valid key, so UDP 51820 looks closed from outside.
  - One key per device. A lost phone is one [Peer] block to delete
    on the VPS, then systemctl restart wg-quick@wg0.
  - The site decides what's reachable. The VPS forwards anything
    between peers, so the site's firewall is what keeps a client to the
    one host it needs. Start with only that host.
  - Clients can reach each other through the hub. If you don't
    want that, replace the VPS's broad route rule with one per
    destination:

      ufw route allow in on wg0 out on wg0 to 192.168.50.0/24
      ufw route allow in on wg0 out on wg0 to 10.99.0.2
      ufw route delete allow in on wg0 out on wg0

    ufw keeps state, so replies coming back to a client still get
    through.

[ When it doesn't work ]

  No handshake             wrong public key or endpoint, UDP 51820 blocked
                           by the VPS provider's own firewall, or the
                           preshared key differs between the two ends.
  Handshake, no LAN        the VPS's site peer is missing the LAN in
                           AllowedIPs, ip_forward is off, the ufw route
                           rule is missing, the site's firewall drops wg0
                           to the LAN, or the Pi isn't masquerading.
  Ping works, pages hang   MTU. Add MTU = 1380 to [Interface] on the
                           client and the site, then test with a packet
                           that can't be split (Linux):
                             ping -M do -s 1352 192.168.50.1
  Dies after idle          the site has no PersistentKeepalive, so CGNAT
                           dropped the mapping.
  Down after an outage     it shouldn't stay down: the site's next
                           keepalive brings the tunnel back within about
                           half a minute of Starlink returning. If it
                           doesn't come back after a power cut, check
                           that wg-quick@wg0 is enabled at boot.
  VPS got a new IP         every config points at the old one. WireGuard
                           looks up a hostname endpoint only when it
                           starts, so a DNS name doesn't fix this by
                           itself. Use your provider's static or reserved
                           IP.

[ FAQ ]

Can you port forward on Starlink?
Not on residential or Roam plans. They sit behind carrier-grade NAT, and
Starlink's router has no port forwarding. A public IPv4 address is only
offered on Local and Global Priority plans.

Does this work with a Starlink Mini?
Yes. Nothing here depends on the dish. The site side is whatever router or
Pi sits behind it, and since it only dials out, it works on the Mini's
built-in router or with the Mini in bypass mode.

How big does the VPS need to be?
The smallest plan any provider sells is plenty for a few devices. If you'll
move big files, check the monthly transfer allowance: everything you pull
from the site passes through the VPS.

Will a changing Starlink IP break the tunnel?
No. The site connects out to the VPS, and WireGuard follows a peer to its
new address with the next packet. Only the VPS needs an address that doesn't
change.

Can the VPS see my traffic?
Yes. Each hop is encrypted, but the hub decrypts and re-encrypts traffic in
the middle. Treat it as part of your network, and keep SSH or HTTPS on
anything sensitive.

Why not just use Tailscale?
You can, and it's easier. Tailscale and the others punch through NAT for you
and relay when they can't. With your own VPS, plain WireGuard means no
account and no coordination server that isn't yours. Headscale is the
self-hosted middle ground.

It's also a lot more software. Tailscale published 17 security bulletins
between January and October 2026. Several were in Tailscale SSH, where a
peer could get root access its ACLs didn't allow (TS-2026-006, TS-2026-009)
or reach root-only sockets (TS-2026-014). Serve could reach the tailscaled
local API socket (TS-2026-016), and OAuth access tokens ended up in audit
logs (TS-2026-003). None touched the WireGuard tunnels themselves, all were
fixed, and every one is public on Tailscale's security bulletins page,
which counts in its favor. If you use it, keep every client updated and
leave Tailscale SSH and Serve off unless you need them. Plain WireGuard has
none of those extras to go wrong.

Can my phone send all its internet through the VPS too?
Yes. Set the phone's AllowedIPs to 0.0.0.0/0, then let the VPS NAT tunnel
traffic out: a MASQUERADE rule for 10.99.0.0/24 on its public interface, and
a ufw route rule from wg0 to that interface. The site's LAN stays reachable
as before.

Does the hub add latency?
Some. Every packet goes through the VPS, so put it near where your Starlink
traffic leaves Starlink's network, not near your house. A traceroute shows
where that is, and it can be far from where the address geolocates.

[ See Also ]