Phone:

Hidden from the page source until you click: friction against scrapers, not a guarantee.

Email:

[email protected]

Category:

Systems Programming

Published:

Tags:
  • wireguard
  • vpn
  • linux
  • networking
  • site-to-site
  • systemd

How to Set Up a WireGuard Site-to-Site VPN Between Two Linux Servers

A host-to-host WireGuard tunnel is the easy case: two machines, two keys, done in five minutes. A site-to-site tunnel 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. That means the tunnel config is the small part. The routing, forwarding and firewall rules either side of it are where people actually get stuck.

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 topology

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 WireGuard and generate keys

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.

Configure Server A

/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) and, on the receiving end, 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, because WireGuard silently drops anything outside AllowedIPs.

Configure Server B

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, and saves you debugging a tunnel that "randomly" stops working after twenty minutes of silence.

Enable IP forwarding

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.

Routing the LAN, not just the servers

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 if it's a secondary box sitting alongside a consumer router, you need to add 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, and then they conclude the VPN is broken when it's actually working exactly as configured.

Firewall rules

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 plenty of distributions ship a default-drop forward chain specifically to stop exactly this kind of accidental transit, so check before assuming.

If you genuinely can't add routes on the remote LAN (a consumer router you don't control, say), the fallback is NAT: 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.

Verify it actually works

sudo wg show wg0

Look for a recent "latest handshake" timestamp on each side, not just a peer listed with no handshake at all, which 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, confirming traffic is actually using the tunnel rather than, say, a stale route or a default gateway swallowing the packet somewhere else.

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, a PPPoE link, another VPN, certain cloud provider overlays, the usable MTU can be lower still. 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, accounting for the 28 bytes of IP/ICMP header the -s flag doesn't include.

One thing worth keeping in mind once this is running: 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 or 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.