Blog

Building Your Own WireGuard VPN: Tying Home and Cloud Together

WireGuard hub connecting a home LAN to cloud services

My ISP's ONT sits behind the carrier, so I don't have a stable public IP and port mappings keep getting recycled. What I wanted was very specific: reach the home NAS when I'm on the road, let a few small cloud services talk to home, and never expose management interfaces over plain cellular.

I tried two WireGuard patterns — every device dialing straight home, and a VPS acting as a hub. I went with the hub: no matter how the home NAT shifts, as long as home keeps one outbound tunnel alive, the outside world can route back through the VPS. This post follows the architecture decisions — addresses first, then policy, and only at the end the config details.

Decision one: hub topology, not full mesh

Full mesh means every peer has to know every other peer's Endpoint. Without a stable home entry point, laptops and phones have no idea "who is the home public IP right now."

The hub model collapses the problem into one sentence: everyone only knows the VPS. The VPS has a fixed IP, opens UDP 51820, and does the forwarding. lab-core dials out and hangs a PersistentKeepalive = 25 on the tunnel so the carrier NAT mapping never goes stale.

The price is one extra hop of latency, and the VPS becomes a single point of failure. For a home setup I'll take that: a few bucks to a few tens of dollars a month beats fighting CGNAT. If I ever need high availability, I'll look at dual hubs or Headscale then — I'm not pre-paying that complexity now.

Why not just buy a commercial mesh product? You can, but I want routing and keys sitting in my own config files, so that when something breaks I can explain it in plain text. The learning cost buys auditability, not performative cred.

Decision two: the address table comes before any conf file

Without an address table, everything downstream is black magic. I sketch it on paper first:

Role Tunnel address Notes
VPS hub 10.66.0.1/24 Public entry, forwarding on
Home lab-core 10.66.0.10 Dials out to hub, can carry LAN routes
Laptop 10.66.0.20 For travel
Phone 10.66.0.30 Config can change anytime

The LAN stays 192.168.1.0/24. The tunnel range and the home LAN must not overlap, or your routing table will fight you. I've seen people use 192.168.1.0/24 for the tunnel too — pings to a fake target succeed while the real service times out forever, and they lose a whole day debugging it.

One keypair per device, and the private key never leaves the box. To generate:

umask 077
wg genkey | tee /etc/wireguard/privatekey | wg pubkey > /etc/wireguard/publickey

Public keys in git are fine; a private key in git is as good as no private key. The address table and the public-key cross-reference live on the first page of my notes.

Decision three: AllowedIPs is policy, not a fill-in-the-blank

A lot of people treat AllowedIPs as a label for "who this peer is." It's actually two things stacked together: which source addresses are allowed to come in from this peer, and which destination subnets this machine should push into this tunnel.

The lab-core peer entry on the VPS:

[Peer]
# lab-core
PublicKey = <lab-pubkey>
AllowedIPs = 10.66.0.10/32, 192.168.1.0/24

Meaning: trust lab-core's tunnel IP, and also hand it any packets destined for the home LAN. If the phone only lists 10.66.0.0/24, it only reaches the tunnel subnet; to touch 192.168.1.10, you add the LAN range — and you have to think the return path through.

For the return path I usually SNAT on lab-core: home devices see lab-core's LAN address as the source, so I don't have to teach a default route to every IoT gadget. Precise allowlisting beats AllowedIPs = 0.0.0.0/0 — the moment you turn that on, DNS and traffic splitting become a second war, and it's easy to misdiagnose it as "WireGuard is broken."

Decision four: forwarding and masquerading live in the interface lifecycle

The hub's /etc/wireguard/wg0.conf skeleton:

[Interface]
Address = 10.66.0.1/24
ListenPort = 51820
PrivateKey = <hub-private>
PostUp = sysctl -w net.ipv4.ip_forward=1
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT
PostUp = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -j ACCEPT
PostDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE

Tying forwarding and NAT to wg-quick's PostUp/PostDown means the rules follow the tunnel when it restarts, eliminating the drift of hand-run iptables commands you forget to remove. And the cloud provider's security group is a separate gate from the system firewall — you need 51820/udp opened in both, so don't trust just one.

On the home end, Endpoint points at the VPS and PersistentKeepalive = 25 is hardcoded. Handshakes occasionally slow down when the cellular network switches; keepalive is more reliable than manually running wg yourself.

Phased rollout: peers first, then the LAN

For the first week I only verify that peers can ping 10.66.0.x. Once wg show shows a latest handshake, then we talk about reaching 192.168.1.10.

When the handshake won't line up, I think in a fixed order instead of randomly editing configs:

  1. Does wg show show a handshake? No means UDP isn't getting through or the key is wrong.
  2. Does the VPS security group / cloud firewall allow 51820/udp?
  3. Did the public keys get swapped, or pick up stray newlines or spaces?
  4. Is the clock skew insane (it happens)? Check timedatectl first.
  5. Does AllowedIPs swallow the return path?

MTU is another common one: on some networks 1420 is more stable than the default, especially on phone hotspots. The symptom is "the handshake is there, but big packets won't move." Ping small first, then scp one big file — that splits the problem cleanly.

The boundary with Tunnel goes in the architecture doc

Cloudflare Tunnel's job is "safely expose this one HTTP service to the internet"; WireGuard's is "I actually want into the LAN." They don't replace each other: Tunnel won't give you an L2 feel across arbitrary ports, and WG won't give you global Anycast or a WAF.

Once you pass seven or eight peers, hand-writing conf files gets old. wg-easy is an option, or migrate to Headscale later — at home scale I still hand-write, and configs go into git (minus private keys). Private keys live in a password manager or a permission-restricted local file; they don't go in the repo or in chat logs.

The architecture doc's front page keeps only three things: the address table, each machine's public key fingerprint (or a short prefix), and the AllowedIPs intent — "tunnel only" vs "includes LAN." When a new person adds a device, they fill in from the table, which is safer than digging through a pile of conf comments.

Treat the hub as network glue, not a second all-in-one router. A clear address table, stable keepalives, and disciplined AllowedIPs mean this network will serve you for a long time. Once the decisions are written down, the config is just an appendix.

← Back to Blog