WireGuard Site-to-Site Between Two Homes
Joining two houses into one routed network, and the NAT, MTU and routing details that decide whether it works

Contents
Remote access VPNs are a solved problem. You install a client, you tap a toggle, your laptop joins the network. Site-to-site is a different animal: two networks in two buildings, joined permanently, with no client software anywhere and no toggle to tap. A machine at my house addresses a machine at my brother’s by its address and the packet simply arrives. Nobody initiates anything. The tunnel is furniture.
That distinction matters because site-to-site is what you actually want for the useful cases. Offsite backups that run at 3 a.m. without a human tapping a toggle. Monitoring that scrapes a remote box’s metrics into your Prometheus. A secondary DNS resolver that keeps working when your primary reboots. All of those need a network that is always there, and a laptop VPN client cannot provide it.
WireGuard is very good at this, largely because it declines to be a VPN in the traditional sense. It is a routing protocol with cryptography attached, it has no concept of connect or disconnect, and its entire configuration for a link like this fits on half a screen. I run two of these and they have both been boring for years, which is the highest compliment available.
The idea you must get right first
WireGuard has one genuinely counterintuitive concept, and every site-to-site failure I have ever debugged traces back to misunderstanding it.
AllowedIPs does two unrelated jobs at once, and the name only describes one of them.
Outbound, it is a routing table. When a packet needs to go out through the wg0 interface, WireGuard looks at the destination address and finds the peer whose AllowedIPs contains it. That peer’s public key encrypts the packet and that peer’s endpoint receives it. If no peer’s AllowedIPs matches the destination, the packet is dropped with no route to host.
Inbound, it is an access control list. When a packet arrives decrypted from a peer, WireGuard checks whether its source address falls inside that peer’s AllowedIPs. If it does not, the packet is discarded. This is called cryptokey routing and it means a peer cannot spoof addresses belonging to another peer, which is a genuinely elegant security property you get for free.
The consequence people trip over: AllowedIPs on peer A must contain every subnet that lives behind peer B. Put only the tunnel address in there and the tunnel comes up, the peers ping each other, and no traffic to any real machine works — the classic “handshake succeeds, nothing routes” symptom. Overlapping AllowedIPs across two peers is likewise a configuration error rather than load balancing; the last matching route wins and you get baffling asymmetry.
Planning, before any keys
Two decisions save you a rebuild later.
Non-overlapping subnets. Both houses running 192.168.1.0/24 is fatal. A packet for 192.168.1.50 at the far end never reaches the tunnel because the local kernel is certain that address is on the LAN. There is no elegant fix; you renumber, or you do source NAT on the tunnel and lose the ability to address remote machines directly, which discards the entire point. Renumber one side. Do it now while there are three devices to change.
- Site A (mine):
192.168.10.0/24 - Site B (brother’s):
192.168.20.0/24 - Tunnel:
10.99.0.0/30— A is10.99.0.1, B is10.99.0.2
If you have already split your network into VLANs, every subnet you intend to expose needs to appear in AllowedIPs, and this is a good moment to be deliberate: the far site probably needs one of your six VLANs rather than all of them.
It is also worth checking, before you start, that Site A’s “public” address is genuinely public. A router that has itself been handed a private address by the ISP’s equipment puts you one layer of NAT deeper than you think, and the port forward you carefully configured terminates on a box that never sees the traffic.
Who has the public address. One side needs a reachable endpoint. If both sides are behind CGNAT with no public IP, neither can be the endpoint and you need a cheap VPS in the middle acting as a rendezvous point, which is a three-node version of the same config. Here I assume Site A has a public address, dynamic or otherwise.
The configuration
Generate keys on each box; private keys never move between machines:
| |
Site A, the side with the public endpoint:
| |
Site B, the side behind NAT:
| |
Bring both up and enable them at boot:
| |
Three details in there earn their place.
PersistentKeepalive = 25 on the NAT’d side only. WireGuard is silent when idle, so the NAT mapping on Site B’s router expires after a minute or two of no traffic and Site A can no longer reach in. A keepalive every 25 seconds holds the mapping open, and 25 is chosen because it beats the shortest NAT timeout in common use. Site A needs no keepalive — it has a public endpoint and nothing to hold open. Set it on both and you burn a little battery and bandwidth for nothing.
The asymmetric Endpoint. Only Site B specifies one, because only Site A is reachable. Site A learns Site B’s current endpoint from the first handshake and updates it automatically whenever B’s address changes. This is roaming, it is free, and it means B can move house without a config change.
AllowedIPs contains the far subnet on each side. Site A lists 192.168.20.0/24 because that is what lives behind B. Getting this backwards is the single most common error and produces a tunnel that handshakes perfectly and routes nothing.
Dynamic addresses
Residential connections change address. Site B roams for free, as above. Site A’s endpoint is written into B’s config, so when A’s address changes, B keeps sending to the old one and the link dies until the next config edit.
Dynamic DNS solves it — a hostname that follows the address, which is what vpn.example.com is doing in the config above. The subtlety: wg-quick resolves the endpoint hostname exactly once, at interface bring-up, and caches the result forever. The DNS record updating does nothing on its own. You need something to re-resolve and poke the peer:
| |
180 seconds is deliberate: WireGuard rehandshakes every two minutes under load, so anything past three minutes means the link is genuinely unhappy rather than merely quiet.
The MTU trap
This is the failure that wastes the most time, because it does not look like a network problem.
WireGuard adds 60 bytes of overhead for IPv4 and 80 for IPv6. On a standard 1500-byte link, wg-quick sets the tunnel MTU to 1420 and everything works. On a connection that is already reduced — PPPoE VDSL takes 8 bytes for 1492, and some carriers do worse — 1420 is too big. The tunnel then needs to fragment, and a great many routers drop the ICMP “fragmentation needed” messages that would tell the sender to send smaller packets. Path MTU Discovery silently fails.
The symptom is memorable: ping works perfectly, SSH connects and then hangs the instant you run ls on a large directory, curl fetches small pages and stalls forever on large ones, and file transfers start fast and freeze at a few hundred kilobytes. Small packets pass; large packets vanish. Everyone’s first instinct is to blame the application.
Find the real path MTU by bisection with the don’t-fragment bit set:
| |
Path MTU is 1492, so the tunnel gets 1492 − 60 = 1432. If in doubt, 1380 is a conservative value that works nearly everywhere and costs a percent or two of throughput — a trade worth making to never think about this again:
| |
Deciding what actually crosses
The config above forwards everything in both directions, which is the wrong default for a link into someone else’s house. My brother is trustworthy; his teenager’s laptop, running whatever it downloaded this week, is a machine I have never audited and have no authority over. The tunnel joins two networks, and it therefore joins two threat surfaces.
So the forward rules get specific once the link is proven:
| |
That reduces “two networks joined” to “four ports joined”, which is what I actually wanted and what the phrase site-to-site quietly oversells. The tunnel is a cable between the buildings; the firewall decides what the cable is for. Skipping this step means a compromise at the far end — a house whose patching habits are entirely outside your control — arrives on your LAN with full reachability.
Troubleshooting
“No handshake at all.” wg show prints no latest handshake line. UDP 51820 is not reaching Site A: check the port forward, check the firewall, and confirm Site A is not itself behind CGNAT. tcpdump -i eth0 udp port 51820 on A tells you instantly whether packets arrive. If they arrive and no handshake completes, the keys are mismatched — you pasted a private key where a public one goes, which is the classic 1 a.m. error.
“Handshake succeeds, the peers ping, nothing else works.” AllowedIPs is missing the far subnet, or ip_forward is off, or the forward chain is dropping. Check in that order. sysctl net.ipv4.ip_forward returning 0 is the answer about a third of the time.
“It works from the router but not from other machines.” The other machines have no route. They send traffic for 192.168.20.0/24 to their default gateway, and a gateway that is some other box has never heard of the tunnel. Either put the route on the LAN router, or add the tunnel box as a static route pushed by DHCP.
“It works for a while and then stops.” Missing PersistentKeepalive on the NAT’d side, or Site A’s dynamic address changed. wg show and read latest handshake — anything over three minutes means the link is down regardless of what the interface claims.
“Throughput is dreadful.” Check MTU first, always. Then check whether the endpoint’s CPU has AES acceleration — WireGuard uses ChaCha20-Poly1305, which is fast on almost anything, though a low-power ARM board will still cap out around 100–200 Mbit/s. A Pi Zero will not saturate a gigabit line.
Making it survive being forgotten
A link like this is only useful if it works when nobody is watching, and the whole point is that nobody watches it. That means the tunnel is only half the build. The other half is the notification that fires when it dies.
WireGuard fails silently and completely. There is no daemon logging a disconnect, because there is no concept of a connection to disconnect. The interface stays up and looks perfectly healthy while routing nothing whatsoever. The failure mode is a latest handshake timestamp that quietly stops advancing, and absolutely nothing draws your attention to it.
So the tunnel gets a health check like anything else. The far end runs a tiny endpoint on the overlay address, my monitoring scrapes it across the tunnel, and a failure pages me. The check exercises the actual path rather than the interface state, which matters because the interface will lie to you.
Without that, the discovery mechanism becomes “I needed the offsite backup and found out it had been dead for five months”. The backup that has been silently failing since spring is the classic homelab disaster, and a permanently-up tunnel is an excellent way to build one. Monitor the link, or accept that you have built a backup you will discover is empty on the worst day of your year.
Is it worth it?
For a permanent link between two buildings you control: yes, and there is no serious competitor. IPsec does the same job with ten times the configuration surface and a debugging experience that will age you. OpenVPN works fine and is meaningfully slower on modest hardware. WireGuard is in the kernel, it is roughly 4,000 lines of code, and the config above is the whole thing.
The honest caveat is that a mesh overlay might suit you better. If you want ten machines across four locations talking to each other, hand-maintaining AllowedIPs on ten peers becomes a chore that scales quadratically, and something with a control plane handles it for you. Site-to-site WireGuard is the right shape specifically when you have two networks rather than many machines — a whole subnet at each end, permanently joined, with no software on the individual hosts.
That shape is exactly what an offsite backup target at a relative’s house needs. Mine has been up, unattended, for years. The one time it broke, my brother’s ISP swapped him to CGNAT overnight and roaming quietly fixed it before I noticed — which is the best possible review of the design.




