How to Build a WireGuard Mesh Between Servers
Build a WireGuard mesh VPN across multiple servers: key generation, per-node configs, AllowedIPs routing logic, NAT traversal, firewall rules, and full verification.
Before you start
- ▸Root or sudo access on all nodes
- ▸WireGuard-capable kernel (5.6+ or DKMS module on older kernels)
- ▸UDP port 51820 reachable between at least the public-IP nodes
- ▸Public IP addresses noted for all non-NAT nodes
WireGuard's peer-to-peer model makes it genuinely well-suited for mesh VPNs, but the default mental model of "one server, many clients" doesn't translate directly. In a mesh, every node is simultaneously a peer to every other node. Getting AllowedIPs, routing, and NAT traversal right is where most people get stuck. This guide walks through a three-node mesh from scratch, then explains how to scale the pattern further.
Prerequisites and Assumptions
You need WireGuard installed on every node, root or sudo access, and each node must be able to reach at least one other node on UDP (the port you choose). The guide uses three nodes:
- node-a — public IP 203.0.113.10, mesh IP 10.10.0.1/24
- node-b — public IP 198.51.100.20, mesh IP 10.10.0.2/24
- node-c — behind NAT (no public IP), mesh IP 10.10.0.3/24
All nodes use UDP port 51820. Adjust addresses and port numbers to match your environment.
Step 1 — Install WireGuard
WireGuard is in the mainline kernel since 5.6 and in the default repos of every major distro.
# Debian / Ubuntu
sudo apt update && sudo apt install -y wireguard
# Fedora / RHEL 9+ / Rocky
sudo dnf install -y wireguard-tools
# Arch
sudo pacman -S wireguard-tools
Step 2 — Generate Keys on Every Node
Run these commands on each node independently. Never copy a private key across machines.
umask 077
wg genkey | tee /etc/wireguard/private.key | wg pubkey > /etc/wireguard/public.key
chmod 600 /etc/wireguard/private.key
Read back the public key — you'll need to paste it into the other nodes' configs:
cat /etc/wireguard/public.key
Collect all three public keys before moving on. Optionally generate a preshared key per pair for post-quantum resistance:
# Run once per pair, on either node
wg genpsk > /etc/wireguard/psk-ab.key # for node-a <-> node-b pair
Step 3 — Write the Interface Configuration
Each node gets a wg0.conf file with one [Interface] section and one [Peer] section per remote node. The key insight: every node must list every other node as a peer.
node-a (/etc/wireguard/wg0.conf)
[Interface]
Address = 10.10.0.1/24
ListenPort = 51820
PrivateKey = <node-a private key>
# Peer: node-b
[Peer]
PublicKey = <node-b public key>
PresharedKey = <psk-ab content>
Endpoint = 198.51.100.20:51820
AllowedIPs = 10.10.0.2/32
PersistentKeepalive = 25
# Peer: node-c (behind NAT — no Endpoint here, it will initiate)
[Peer]
PublicKey = <node-c public key>
PresharedKey = <psk-ac content>
AllowedIPs = 10.10.0.3/32
node-b (/etc/wireguard/wg0.conf)
[Interface]
Address = 10.10.0.2/24
ListenPort = 51820
PrivateKey = <node-b private key>
[Peer]
PublicKey = <node-a public key>
PresharedKey = <psk-ab content>
Endpoint = 203.0.113.10:51820
AllowedIPs = 10.10.0.1/32
PersistentKeepalive = 25
[Peer]
PublicKey = <node-c public key>
PresharedKey = <psk-bc content>
AllowedIPs = 10.10.0.3/32
node-c (/etc/wireguard/wg0.conf — NAT node)
[Interface]
Address = 10.10.0.3/24
ListenPort = 51820
PrivateKey = <node-c private key>
[Peer]
PublicKey = <node-a public key>
PresharedKey = <psk-ac content>
Endpoint = 203.0.113.10:51820
AllowedIPs = 10.10.0.1/32
PersistentKeepalive = 25
[Peer]
PublicKey = <node-b public key>
PresharedKey = <psk-bc content>
Endpoint = 198.51.100.20:51820
AllowedIPs = 10.10.0.2/32
PersistentKeepalive = 25
Step 4 — AllowedIPs Explained
AllowedIPs does two jobs simultaneously: it is both a routing decision and a packet filter. When WireGuard sends a packet, it routes it to the peer whose AllowedIPs range covers the destination. When it receives a packet, it only accepts it if the source IP falls inside that peer's AllowedIPs.
For a mesh, use /32 (or /128 for IPv6) per peer — the exact tunnel IP. Using a wider range like 0.0.0.0/0 on any peer would funnel all traffic through that peer, turning the mesh into a hub-and-spoke. Only do that intentionally if you want one exit node.
If nodes also have private subnets behind them that need to be reachable across the mesh, add those CIDRs to AllowedIPs on the relevant peer entry. For example, if node-a sits in front of 192.168.1.0/24:
# In node-b's and node-c's [Peer] block for node-a:
AllowedIPs = 10.10.0.1/32, 192.168.1.0/24
Step 5 — NAT Considerations
node-c has no public IP. It can only receive connections after it has punched a hole in its NAT by initiating outbound traffic. That's what PersistentKeepalive = 25 accomplishes: it sends a keepalive every 25 seconds, maintaining the NAT mapping so the public nodes can respond.
node-a and node-b do not set an Endpoint for node-c initially — WireGuard learns node-c's roaming endpoint dynamically once it makes contact. This works well in practice.
If two NATted nodes need to talk directly (node-c to a hypothetical node-d, both behind NAT), UDP hole punching is unreliable without a coordination mechanism. The simplest fix is to route that traffic through a public relay node by temporarily widening AllowedIPs, or to use a tool like DERP relaying as an overlay. Pure WireGuard does not handle double-NAT traversal automatically.
Step 6 — Open the Firewall
# ufw (Debian/Ubuntu)
sudo ufw allow 51820/udp
# firewalld (Fedora/RHEL/Rocky)
sudo firewall-cmd --permanent --add-port=51820/udp
sudo firewall-cmd --reload
# nftables — add inside your inet filter input chain
nft add rule inet filter input udp dport 51820 accept
If a node is acting as a transit point (forwarding between subnets behind it), you also need IP forwarding enabled:
echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-wg-forward.conf
sudo sysctl -p /etc/sysctl.d/99-wg-forward.conf
Step 7 — Start and Enable with systemd
Run this on every node:
sudo systemctl enable --now wg-quick@wg0
The wg-quick@wg0 service reads /etc/wireguard/wg0.conf, brings up the interface, sets routes, and persists across reboots automatically.
Step 8 — Verify the Mesh
On each node, check WireGuard's view of its peers:
sudo wg show
Healthy output shows each peer with a recent latest handshake timestamp (within the last 3 minutes if keepalive is active) and a non-zero transfer counter. A peer stuck at (none) for handshake means no traffic has been exchanged yet — check firewall rules and endpoints.
Ping across the mesh to confirm full connectivity:
# From node-a
ping -c3 10.10.0.2 # -> node-b
ping -c3 10.10.0.3 # -> node-c
Run a quick MTR or traceroute if a path is slow or dropping packets:
mtr --report 10.10.0.3
Troubleshooting
- Handshake never completes: The most common cause is a firewall blocking UDP 51820. Run
tcpdump -n -i eth0 udp port 51820on the receiving node to see if packets arrive at all. - Ping works one way but not the other: AllowedIPs mismatch — the source IP on the reply probably doesn't match any AllowedIPs rule on the receiving end.
- Node behind NAT unreachable from public nodes: Confirm PersistentKeepalive is set and the NAT node has initiated at least one handshake. Run
sudo wg showon the public node and check whether an endpoint has been learned for the NAT peer. - Routes not appearing:
wg-quickadds routes automatically based on AllowedIPs. If they're missing, checkip route show table mainandip route show table 51820. Restarting the interface withsudo systemctl restart wg-quick@wg0often resolves stale state. - Performance lower than expected: WireGuard MTU defaults to 1420 bytes. If your underlay uses jumbo frames or has a lower MTU (e.g., PPPoE at 1492), add
MTU = 1412(or appropriate value) to the[Interface]section.
Frequently asked questions
- Can two nodes that are both behind NAT communicate directly without a relay?
- Not reliably with plain WireGuard. UDP hole punching requires precise timing coordination that WireGuard doesn't implement. Route traffic through a public node or use an overlay that handles DERP-style relaying.
- Why use /32 in AllowedIPs instead of the full /24 subnet?
- AllowedIPs acts as a routing rule: WireGuard sends packets destined for that CIDR through that specific peer. A /24 on one peer would route all mesh traffic through it, breaking direct peer-to-peer paths.
- What does PresharedKey add, and is it required?
- A preshared key adds a symmetric layer on top of WireGuard's asymmetric handshake, providing resistance against future quantum attacks. It's optional but recommended for long-lived infrastructure tunnels.
- How do I add a fourth node to an existing mesh without downtime?
- Generate keys on the new node, then append a [Peer] block for it to each existing node's wg0.conf and run 'wg addconf wg0 /dev/stdin' or reload with systemctl restart. Existing tunnels between the original nodes are not disrupted.
- Why does wg show display no endpoint for a NAT peer even though keepalives are set?
- The NAT node must initiate the first handshake before the public node learns its external endpoint. Check that the NAT node's interface is up and its PersistentKeepalive timer has fired at least once.
Related guides
Common Linux Network Ports Reference
Learn Linux port ranges, read /etc/services, find what's listening with ss and nmap, and apply solid firewall rules to expose or block the right ports.
How to Configure a Static IP on Linux
Configure a static IP on Linux using Netplan, NetworkManager (nmcli), or systemd-networkd across Ubuntu, Fedora, Debian, and Arch with verified steps.
How TCP/IP Networking Actually Works
Trace a TCP connection from socket to wire: routing table lookups, ARP, the three-way handshake, MTU/PMTUD, and how NAT rewrites packets on a Linux gateway.
An Introduction to TCP/IP
Learn how TCP/IP works — IP addressing, routing, TCP vs UDP, ports, DNS, and the layered model — with practical Linux commands to see it all in action.