Joining Two Linux Sites Together with WireGuard
In a site-to-site WireGuard setup, the tunnel config is the small part. The routing, forwarding and firewall rules either side of it are where people actually get stuck.
A host-to-host tunnel is the easy case: two machines, two keys, done in five minutes. Site-to-site is a different job. You're not just letting two servers talk to each other, you're letting an entire network behind Server A reach an entire network behind Server B, and vice versa.
This walks through a concrete setup: two Linux servers, each acting as the gateway for a LAN behind it, joined over a WireGuard tunnel across the public internet.
The layout we're building
Assume this layout, with addresses drawn from documentation ranges so nothing here is a real host:
- Server A: public IP 203.0.113.10, fronts LAN 10.10.0.0/24
- Server B: public IP 198.51.100.20, fronts LAN 10.20.0.0/24
- WireGuard tunnel subnet: 10.0.0.0/30 (10.0.0.1 on A, 10.0.0.2 on B)
The goal is for a host on 10.10.0.0/24 to reach a host on 10.20.0.0/24 and back, with both servers acting as routers for their local subnet.
Install the tools and generate a keypair per server
On modern kernels (5.6+) WireGuard is already in-tree; you just need the userspace tools:
sudo apt install wireguard # Debian/Ubuntu
sudo dnf install wireguard-tools # Fedora/RHEL-family
Generate a keypair on each server. Never let the private key leave the box it was generated on:
umask 077
wg genkey | tee privatekey | wg pubkey > publickey
Do this on both servers. You'll need each side's public key on the other side, and nothing else.
Server A: AllowedIPs does the real work
/etc/wireguard/wg0.conf on Server A:
[Interface]
PrivateKey = <Server A private key>
Address = 10.0.0.1/30
ListenPort = 51820
[Peer]
PublicKey = <Server B public key>
AllowedIPs = 10.0.0.2/32, 10.20.0.0/24
Endpoint = 198.51.100.20:51820
PersistentKeepalive = 25
The AllowedIPs line is the whole trick of a site-to-site setup. It's doing two jobs at once:
- It's the routing table entry: traffic to 10.20.0.0/24 goes into this tunnel.
- On the receiving end, it's the packet filter: only packets claiming to be from that range are accepted from this peer.
Forget the LAN subnet here and the tunnel comes up fine, handshakes succeed, and nothing routes. That's because WireGuard silently drops anything outside AllowedIPs.
Server B: the mirror image
Mirror it on Server B, swapping the roles:
[Interface]
PrivateKey = <Server B private key>
Address = 10.0.0.2/30
ListenPort = 51820
[Peer]
PublicKey = <Server A public key>
AllowedIPs = 10.0.0.1/32, 10.10.0.0/24
Endpoint = 203.0.113.10:51820
PersistentKeepalive = 25
PersistentKeepalive only matters if one side sits behind NAT that will forget the UDP mapping if the tunnel goes quiet. If both servers have stable public IPs and no NAT in front of them, you can drop it.
It costs nothing to leave it in, though. It saves you debugging a tunnel that "randomly" stops working after twenty minutes of silence.
Turn on IP forwarding, or nothing gets past the servers
Both servers need to forward packets between the tunnel and the LAN interface, which the kernel refuses to do by default:
echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-wireguard.conf
sudo sysctl --system
This is the single most common thing people forget, and the symptom is confusing. The tunnel itself works: you can ping the other server's tunnel address, 10.0.0.1 or 10.0.0.2. But nothing behind either server can reach anything behind the other.
Check it with sysctl net.ipv4.ip_forward; it should read 1.
Bring the tunnel up
sudo systemctl enable --now wg-quick@wg0
On both ends. wg-quick reads the interface name from the filename, so wg0.conf becomes wg-quick@wg0.
The LAN hosts still need a route to the tunnel
At this point the two servers can reach each other's tunnel address and, thanks to ip_forward, each other's LAN. But other hosts on those LANs still don't know the tunnel exists.
If Server A is already the default gateway for 10.10.0.0/24, nothing further is needed on that side. All outbound traffic already passes through it, and its routing table (populated by AllowedIPs) sends 10.20.0.0/24 traffic into wg0.
If Server A is not the default gateway for its LAN, for instance a secondary box sitting alongside a consumer router, you need a static route on the LAN's actual gateway (or on each host) pointing the remote subnet at Server A:
# on the LAN gateway or each host at site A
sudo ip route add 10.20.0.0/24 via 10.10.0.1
where 10.10.0.1 is Server A's LAN-facing address. Repeat the mirror image at site B.
This is the step people skip, because it happens outside the WireGuard config entirely. Then they conclude the VPN is broken when it's actually working exactly as configured.
Open the port and permit forwarding in the firewall
You need the tunnel port open, and you need forwarding permitted between the tunnel interface and the LAN interface. With nftables:
sudo nft add rule inet filter input udp dport 51820 accept
sudo nft add rule inet filter forward iifname "wg0" oifname "eth0" accept
sudo nft add rule inet filter forward iifname "eth0" oifname "wg0" accept
Adjust eth0 to whatever the LAN-facing interface actually is. If the default forward policy is already accept, these forward rules are redundant.
But check before assuming. Plenty of distributions ship a default-drop forward chain specifically to stop exactly this kind of accidental transit.
Quick detour: when you can't add routes, NAT
If you genuinely can't add routes on the remote LAN (a consumer router you don't control, say), the fallback is NAT. You masquerade LAN traffic as it enters the tunnel, so replies come back to the server itself rather than needing a route on the far side.
That's a real trade-off, not just a shortcut. It hides the true source address from the far end, which breaks anything there that logs or filters by client IP. Prefer routing; reach for masquerade only when routing genuinely isn't available to you.
Check it from a LAN host, not just the servers
sudo wg show wg0
Look for a recent "latest handshake" timestamp on each side. A peer listed with no handshake at all usually means the UDP port never arrived; check upstream firewalls and NAT/port-forwarding if either server sits behind one.
Then test from an actual LAN host, not just the servers themselves:
# from a host on 10.10.0.0/24
ping 10.20.0.5
traceroute 10.20.0.5
The traceroute should show the hop through 10.0.0.1 or 10.0.0.2. That confirms traffic is actually using the tunnel rather than, say, a stale route or a default gateway swallowing the packet somewhere else.
Pings work, big transfers stall: the MTU trap
WireGuard's overhead means the effective MTU across the tunnel is smaller than the underlying link, typically landing around 1420 for a standard 1500-byte Ethernet path. If the real path between the two servers has extra encapsulation of its own, the usable MTU can be lower still. Common culprits:
- a PPPoE link
- another VPN
- certain cloud provider overlays
The failure mode is specific. Small packets (pings, DNS queries) work fine, while anything larger stalls, because it needs fragmentation that something in the path is silently dropping instead of permitting.
Test for it directly rather than guessing:
ping -M do -s 1400 10.20.0.5
Reduce the size until it stops failing, and set MTU = <value> in the [Interface] section on both ends. Account for the 28 bytes of IP/ICMP header the -s flag doesn't include.
AllowedIPs drifts quietly over time
Once this is running, keep one thing in mind: AllowedIPs has to match exactly on both peer definitions relative to what's actually routed. If you later add a third subnet at either site, it needs adding on both sides. Otherwise the return traffic gets dropped by the peer that doesn't know about it.
It's a config that drifts quietly if you're not the one who touches it next time. The WireGuard documentation is worth keeping bookmarked for exactly that reason.