$linuxjunkies
>

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.

AdvancedUbuntuDebianFedoraArch10 min readUpdated May 26, 2026

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 51820 on 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 show on the public node and check whether an endpoint has been learned for the NAT peer.
  • Routes not appearing: wg-quick adds routes automatically based on AllowedIPs. If they're missing, check ip route show table main and ip route show table 51820. Restarting the interface with sudo systemctl restart wg-quick@wg0 often 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.
tested on:Ubuntu 24.04Debian 12Fedora 40Arch rolling

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