Wireguard mesh between three personal servers: hub-and-spoke vs full-mesh, and the AllowedIPs that bite you in either

I run three personal servers — one Oracle ARM box (the Internet-facing one), a NAS at home, and a tiny N100 mini-PC at a friend’s place that I use as off-site backup. They need to reach each other privately. Tailscale would handle this in 30 seconds, and you should probably use Tailscale. But sometimes you want vanilla Wireguard with no third-party control plane and no userspace daemon. Three nodes is exactly the size where the topology question matters: hub-and-spoke or full mesh?

Here’s the practical comparison and the AllowedIPs gotcha that bites both setups in different ways.

Hub-and-spoke

One node (the hub) is configured as a peer of every other; the spokes are configured as peers of only the hub. Cross-spoke traffic gets routed through the hub.

  • Pro: trivially simple. Adding a 4th node = one config edit on the hub, one config on the new node. No N² explosion.
  • Pro: spokes don’t need to know each other’s public keys or current addresses. The hub is the only thing that has to be reachable.
  • Con: the hub is a SPOF. If the hub is offline, no spoke can reach any other spoke.
  • Con: cross-spoke latency is hub-relative. NAS-to-mini-PC traffic flows NAS → Oracle → mini-PC, even though they could in theory hit each other directly.
  • Con: the hub’s bandwidth is the cross-spoke bandwidth ceiling.

Full mesh

Every node is a peer of every other node. Three nodes = three pairwise connections.

  • Pro: no SPOF. If any one node is down, the other two still reach each other directly.
  • Pro: optimal latency. Every pair takes the direct path.
  • Pro: bandwidth is per-link, not bottlenecked at a hub.
  • Con: N² configuration. With 3 nodes that’s 3 connections; with 5 it’s 10; with 10 it’s 45. Manual config doesn’t scale past ~5 nodes.
  • Con: every node needs a stable, reachable address to its peers — direct internet exposure or NAT-traversal hacks. Wireguard alone has no STUN/TURN.

For three nodes, full mesh wins

The “N² explosion” doesn’t matter at N=3. The “every node needs a reachable address” is solved by giving each node a public IP or running endpoint behind Endpoint= with a dynamic DNS hostname. Three configs is fine to maintain by hand.

The full-mesh setup for my three nodes:

# /etc/wireguard/wg0.conf — on Oracle (10.42.0.1)
[Interface]
PrivateKey = ...oracle-priv...
Address = 10.42.0.1/24
ListenPort = 51820

[Peer]   # NAS
PublicKey = ...nas-pub...
AllowedIPs = 10.42.0.2/32
Endpoint = nas.your-ddns.com:51820
PersistentKeepalive = 25

[Peer]   # mini-PC
PublicKey = ...mini-pub...
AllowedIPs = 10.42.0.3/32
Endpoint = mini.your-ddns.com:51820
PersistentKeepalive = 25

Each other node has a similar config with itself in [Interface] and the other two as peers.

The AllowedIPs trap

AllowedIPs is the most misunderstood thing about Wireguard. It does two things at once:

  • Inbound filter: packets coming in over the tunnel from this peer are accepted only if their source IP is in this list. Reject everything else.
  • Outbound routing: packets your kernel wants to send to any IP in this list will be routed over the tunnel to this peer.

Confusing the two leads to weird behaviour. The two scenarios that bit me:

Trap 1: AllowedIPs = 0.0.0.0/0 on a hub-and-spoke spoke

Tutorials for “use Wireguard as a VPN” tell you to set AllowedIPs = 0.0.0.0/0 on the spoke side — meaning “send all my internet traffic through this tunnel.” Fine for a VPN. Catastrophic for a mesh: it tells the spoke that every IP belongs to that peer. SSH into the spoke from anywhere now tries to route the response through Wireguard, which doesn’t have a route back, and the connection hangs.

For a mesh, AllowedIPs should be the narrow set of mesh IPs only — 10.42.0.0/24 at most, often just the specific peer’s /32.

Trap 2: overlapping AllowedIPs in full mesh

If two peers both have AllowedIPs = 10.42.0.0/24, the kernel routes outbound mesh traffic to whichever peer the routing table picked first. The other peer’s traffic gets rejected on inbound (wrong source, per the filter side) — and the rejection is silent.

The fix: each peer’s AllowedIPs should be that peer’s specific mesh IP (a /32), not the whole subnet:

# Wrong — overlapping
[Peer]
AllowedIPs = 10.42.0.0/24   # NAS
[Peer]
AllowedIPs = 10.42.0.0/24   # mini-PC

# Right — specific
[Peer]
AllowedIPs = 10.42.0.2/32   # NAS
[Peer]
AllowedIPs = 10.42.0.3/32   # mini-PC

Now the kernel routes 10.42.0.2 to NAS and 10.42.0.3 to mini-PC. No ambiguity, no silent rejection.

The keepalive question

PersistentKeepalive = 25 sends a small UDP packet every 25 seconds. It’s only needed when one or both peers are behind NAT and you want the NAT mapping to stay alive. For two cloud servers with public IPs, leave it off — saves a tiny amount of packet traffic.

For my mesh: NAS is behind home NAT, mini-PC is behind friend’s NAT, Oracle has a public IP. The home NAS and the mini-PC both need keepalives toward Oracle (NAT punchthrough). Keepalive between NAS and mini-PC is also needed because both are NATed — without it, the connection sits idle, NAT mappings expire on both sides, and the next packet has nowhere to land.

Should you just use Tailscale?

Honest answer: yes, for most cases. Tailscale handles the AllowedIPs accounting, the NAT traversal, the key rotation, and adding a fourth or fifth node automatically. The only reasons to roll your own Wireguard mesh are:

  • You don’t want a third-party control plane.
  • Your nodes can’t reach Tailscale’s coordination servers (rare, but happens in air-gapped scenarios).
  • You want to learn how Wireguard actually works.

For the third reason, a three-node full mesh is the perfect-sized lab. You’ll hit every interesting failure mode (NAT, AllowedIPs, key rotation) without the complexity of a 10-node deployment.

Cover photo: Mikhail Nilov on Pexels.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.