WireGuard Hub-and-Spoke at Scale: Route Management and IP Pool Design That Won’t Break at 200 Peers

Most WireGuard tutorials stop at two peers. That’s fine for a personal VPN, but the moment you’re managing a fleet of servers, remote workers, or branch offices, you need a real architecture — not a glorified lab setup.

Hub-and-spoke is the right model for most of these scenarios. One central node (the hub) terminates all tunnels; everyone else (spokes) connects to it. Traffic between spokes flows through the hub. Simple, auditable, and it scales predictably — if you plan it right from the start.

Where teams go wrong is in two specific places: IP pool design (picked on day one, painful to change later) and route management (what AllowedIPs actually does and how it interacts with the kernel routing table). This article fixes both.

The official WireGuard repo and docs live at https://github.com/WireGuard/wireguard-linux and https://www.wireguard.com/.


Why Hub-and-Spoke Over Full Mesh

Full mesh sounds appealing until you do the math. With n peers, you need n*(n-1)/2 tunnel pairs. At 50 nodes that’s 1225 unique connections to maintain, rotate keys for, and troubleshoot. Every node needs firewall rules and public reachability for every other node.

Hub-and-spoke gives you n-1 tunnels total. The hub needs a public IP. Spokes don’t. A spoke behind a strict NAT can still reach every other spoke. You centralize policy, logging, and failure domains. The tradeoff: the hub is a single point of failure, which is solvable and covered later.


IP Pool Design: Get This Right Before You Touch a Config

The biggest mistake is picking /24 (256 addresses) because "we’ll never need more than that." WireGuard peers are identified by their AllowedIPs entries on the hub, and those entries end up in your kernel’s routing table. A well-chosen address space saves you a painful renumbering later.

The Range

Pick a dedicated subnet from RFC 1918 space that doesn’t overlap with your production networks. Common mistakes:

  • 10.0.0.0/24 — almost certainly conflicts with your cloud VPC or office LAN
  • 192.168.1.0/24 — this is the default for every consumer router on the planet

Good candidates:

  • 10.20.0.0/16 — gives you 65534 peers, plenty of room
  • 172.20.0.0/14 — the whole 172.20-23.x.x range, rarely used by default

Use a /16 minimum. You’ll subdivide it logically, and you want room to grow without touching the hub’s interface address.

Logical Segmentation Within the Pool

Don’t flatten everything into one /16. Segment by role from day one:

10.20.0.0/16  — total WireGuard pool

  10.20.0.0/24   — hub infrastructure (the hub itself, monitoring, DNS resolvers)
  10.20.1.0/24   — remote workers
  10.20.2.0/24   — branch offices
  10.20.3.0/24   — IoT / edge devices
  10.20.16.0/20  — staging servers
  10.20.32.0/20  — production servers

Why does this matter? When you configure AllowedIPs on the hub for a spoke, you’re telling WireGuard "route packets with these destination IPs through this peer’s tunnel." If all your production servers sit in 10.20.32.0/20, you can write a single AllowedIPs entry that covers them all, instead of one /32 per server. This directly impacts how many routes land in the kernel routing table.

Assign Hub Address First

The hub should have a static address from the infrastructure block. Convention: pick .1 of your hub subnet.

Hub wg0 address: 10.20.0.1/16

The /16 prefix length here is intentional. It tells the kernel "this interface owns the entire 10.20.0.0/16 space for local delivery." Spokes get /32 addresses on their own interfaces.


Hub Configuration

Here’s a production-grade hub config. Read the comments — they’re not decorative.

# /etc/wireguard/wg0.conf — HUB

[Interface]
# The hub's VPN address. /16 = owns the whole pool for local routing.
Address = 10.20.0.1/16

# Standard WireGuard port. Keep it consistent across environments.
ListenPort = 51820

# Load key from file, not inline — easier to rotate and audit.
PostUp = wg set %i private-key /etc/wireguard/private.key

# Enable IP forwarding and set up NAT if spokes need internet via hub.
# Remove the MASQUERADE rule if you're doing internal routing only.
PostUp   = sysctl -w net.ipv4.ip_forward=1
PostUp   = iptables -A FORWARD -i %i -j ACCEPT
PostUp   = iptables -A FORWARD -o %i -j ACCEPT
PostUp   = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE

PostDown = iptables -D FORWARD -i %i -j ACCEPT
PostDown = iptables -D FORWARD -o %i -j ACCEPT
PostDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE

# DNS for peers (optional, only relevant if peers use hub as resolver)
# DNS = 10.20.0.53

# --- SPOKE PEERS ---
# Each peer block here represents one spoke.
# AllowedIPs is the key: it defines what traffic is routed TO this peer.

[Peer]
# spoke-worker-01
PublicKey = <spoke-public-key-here>
# This spoke has a fixed VPN IP — only route that exact /32 to it.
AllowedIPs = 10.20.1.10/32

[Peer]
# branch-office-berlin
PublicKey = <berlin-public-key-here>
# This office has a whole subnet behind it — route the entire block.
AllowedIPs = 10.20.2.0/24, 192.168.100.0/24
# ↑ 192.168.100.0/24 is the office's LAN — traffic to it goes through this peer.

[Peer]
# prod-server-42
PublicKey = <prod-key-here>
AllowedIPs = 10.20.32.42/32
PersistentKeepalive = 25
# ↑ PersistentKeepalive on the HUB side only if the hub needs to initiate.
# Usually set it on the spoke side instead.

Generate keys properly:

# Never commit these to git
wg genkey | tee /etc/wireguard/private.key | wg pubkey > /etc/wireguard/public.key
chmod 600 /etc/wireguard/private.key

Spoke Configuration

The spoke config is simpler. Spokes don’t route for other peers — they just know where the hub is and what traffic to send through the tunnel.

# /etc/wireguard/wg0.conf — SPOKE (remote worker example)

[Interface]
# Each spoke gets a unique /32 from the pool.
Address = 10.20.1.10/32
PostUp = wg set %i private-key /etc/wireguard/private.key

[Peer]
# The hub
PublicKey = <hub-public-key>
Endpoint  = hub.example.com:51820

# Split tunnel: only send VPN-bound traffic through the hub.
# 10.20.0.0/16 covers all VPN peers.
# Add specific corporate subnets as needed.
AllowedIPs = 10.20.0.0/16, 172.16.0.0/12

# If you want full tunnel (route ALL traffic through hub):
# AllowedIPs = 0.0.0.0/0, ::/0

# Keeps the tunnel alive through NAT. Essential for spokes behind firewalls.
PersistentKeepalive = 25

Gotcha #1: AllowedIPs is a routing rule, not a firewall rule.
When you put 0.0.0.0/0 in AllowedIPs on a spoke, you’re telling the kernel "send everything through this peer." That overwrites your default route. If the hub goes down, the spoke loses all internet access, not just VPN access. This is intentional for corporate full-tunnel scenarios but catastrophic if you forget it.

Gotcha #2: AllowedIPs on the hub must be exact.
If two peers share any IP address in their AllowedIPs, WireGuard will reject the second one silently or overwrite the first entry in the routing table. This is a silent failure. Enforce uniqueness in your IPAM system before it bites you.


Route Management: What’s Actually Happening in the Kernel

WireGuard creates a virtual network interface (wg0). Every entry in a peer’s AllowedIPs becomes a route in your kernel routing table pointing to that interface, then WireGuard resolves which peer to actually encrypt the packet for.

Check what’s in there:

ip route show table main | grep wg0
# or
wg show wg0

For scale, you care about two things:

Kernel routing table size: Linux handles hundreds of thousands of routes easily in the main table. WireGuard doesn’t have a peer limit in the kernel module itself — the practical limit is memory and the cost of looking up the right peer for each packet. At 500+ peers with /32 entries, profile your hub’s CPU on packet forwarding before going live.

The AllowedIPs CIDR trie: WireGuard internally uses a radix trie keyed on AllowedIPs to find the right peer for outbound packets. Lookups are O(log n) on prefix length, not O(n) on peer count. This is why aggregating prefixes matters — 10.20.32.0/20 is one trie lookup, not 4096.

Aggregate Wherever You Can

If you assigned 10.20.32.0/20 to production servers, and each server has its own peer entry with a /32, you can still add an aggregate route on the hub’s interface level. But you cannot aggregate AllowedIPs across multiple peers — those are per-peer.

The lesson: design your pool so that peers that need to talk to each other frequently sit in the same subnet, making inter-spoke routing predictable and aggregate-friendly.


Spoke-to-Spoke Traffic

In hub-and-spoke, spoke-to-spoke traffic transits the hub. Spoke A sends to Spoke B’s VPN IP → hub receives it → hub forwards to Spoke B’s tunnel.

For this to work, the hub needs ip_forward enabled (already in the config above) and both spokes’ IPs must be in the hub’s routing table via their respective AllowedIPs.

# Verify ip_forward is active
sysctl net.ipv4.ip_forward
# Should return: net.ipv4.ip_forward = 1

# Make it permanent
echo "net.ipv4.ip_forward = 1" >> /etc/sysctl.d/99-wireguard.conf
sysctl --system

Gotcha #3: Spokes need each other in their AllowedIPs.
If Spoke A has AllowedIPs = 10.20.0.0/16 pointing at the hub, and Spoke B is at 10.20.1.20/32, then A can reach B (via hub) — the /16 covers it. But if you’re running split tunnel and A only has AllowedIPs = 10.20.0.1/32 (just the hub), A can’t reach B at all because there’s no route for 10.20.1.20 on A’s side. Use the full pool range 10.20.0.0/16 in spoke AllowedIPs for any spoke that needs to reach other spokes.


Dynamic Peer Management at Scale

Managing 10 peers in a flat config file is fine. At 100+, you need automation. Don’t regenerate and reload the entire config for every peer addition — use wg set for live updates without dropping existing tunnels.

Adding a peer without disruption:

# Add a new peer to the running WireGuard interface
wg set wg0 peer <new-public-key> \
  allowed-ips 10.20.1.55/32 \
  persistent-keepalive 25

# Also persist to config file so it survives reboots
wg addconf wg0 <(echo "[Peer]
PublicKey = <new-public-key>
AllowedIPs = 10.20.1.55/32
PersistentKeepalive = 25")

Removing a peer:

wg set wg0 peer <public-key> remove
# Then remove from wg0.conf manually or via sed

For a full IPAM + peer registration system, the pattern is:

  1. New spoke generates a keypair and sends its public key to a registration endpoint
  2. The hub’s management script picks the next available IP from the pool, calls wg set, appends to config
  3. Returns the assigned IP and hub public key to the spoke

Tools like wg-easy or headscale (Tailscale-compatible) automate this entire loop. For anything beyond ~50 manually managed peers, seriously consider headscale — it speaks the Tailscale control protocol, handles key distribution, and has a real IPAM.


Production-Ready: IP Pool Management Script

A minimal IPAM helper to track allocations without a full database:

#!/usr/bin/env bash
# wg-ipam.sh — assign next available IP from pool
# Usage: wg-ipam.sh assign <peer-name>
#        wg-ipam.sh list

POOL_BASE="10.20.1"   # Change per segment
POOL_START=10
POOL_END=250
STATE_FILE="/etc/wireguard/ipam.txt"  # format: ip,peername,pubkey

touch "$STATE_FILE"

case "$1" in
  assign)
    PEER_NAME="$2"
    for i in $(seq $POOL_START $POOL_END); do
      CANDIDATE="${POOL_BASE}.${i}"
      if ! grep -q "^${CANDIDATE}," "$STATE_FILE"; then
        echo "${CANDIDATE},${PEER_NAME}," >> "$STATE_FILE"
        echo "$CANDIDATE"
        exit 0
      fi
    done
    echo "ERROR: pool exhausted" >&2; exit 1
    ;;
  list)
    column -t -s',' "$STATE_FILE"
    ;;
  *)
    echo "Usage: $0 {assign <name>|list}"; exit 1
    ;;
esac

This is intentionally minimal. For anything stateful across multiple hub nodes, replace the flat file with a Postgres table or etcd.


Hub High Availability

The hub is the single point of failure. Two real options:

Option 1: Floating IP / anycast. Run two hub nodes. Use keepalived or your cloud provider’s floating IP. When the primary dies, the IP moves to the secondary. Spokes reconnect automatically using PersistentKeepalive. State (peer handshakes) is lost on failover but re-established within seconds.

Option 2: Separate hubs per region. Don’t try to make one global hub. Run a hub per geographic region. Spokes connect to their nearest hub. Hubs peer with each other (hub-to-hub tunnels) for cross-region spoke communication. This is the architecture that actually scales past a few hundred peers without hub CPU becoming the bottleneck.

Gotcha #4: WireGuard state is not replicated. There’s no active-active WireGuard HA without external tooling. Don’t try to sync /dev/net/tun state between nodes. Accept the failover reconnect window instead.


Monitoring

What you need to watch:

# Peer status and last handshake time
watch -n 5 wg show wg0

# A peer is "connected" if last handshake was < 3 minutes ago.
# PersistentKeepalive = 25 means renegotiation every ~25 seconds, so
# if you see "last handshake: 3+ minutes ago", that peer is effectively dead.

# Bytes transferred per peer — useful for detecting silent failures
wg show wg0 transfer

# Kernel routing table sanity check
ip route show table main | grep wg0 | wc -l
# Compare this to your expected peer count

For Prometheus, prometheus-wireguard-exporter exposes handshake age, transfer bytes, and peer count. Set an alert if last_handshake_seconds > 180 for any peer that should be up.


Gotcha Roundup

  • Overlapping AllowedIPs between peers — silent route collision, one peer stops working. Enforce uniqueness in IPAM before adding any peer.
  • Missing PersistentKeepalive on NAT’d spokes — the NAT mapping expires, the tunnel goes silent. Always set PersistentKeepalive = 25 on spokes behind NAT.
  • ip_forward not persistent — works after reboot if you set it with sysctl -w but not in /etc/sysctl.d/. Add it to a sysctl.d file.
  • DNS leaking on full-tunnel spokes — if you push 0.0.0.0/0 but don’t set DNS on the spoke’s WireGuard interface, DNS queries still go out the default interface. Set DNS = in the [Interface] block or the OS will leak them.
  • Clock skew breaking handshakes — WireGuard’s handshake has a replay window. If the hub’s clock is more than ~180 seconds off from a spoke’s clock, handshakes fail silently. Run chrony or ntp on everything.
  • Config file permissions — /etc/wireguard/wg0.conf must be chmod 600. wg-quick refuses to run it otherwise, and the private key is in there if you’re not loading from a separate file.

What "At Scale" Actually Looks Like

A setup I’ve seen work well for 200+ peers on a single hub:

  • Hub: 2 vCPU, 2 GB RAM VPS (this is plenty for routing, not bandwidth)
  • Pool: 10.20.0.0/16, segmented by team/function
  • Peer management: small Go service with a Postgres backend, exposes a registration API
  • Config sync: hub’s wg0.conf is regenerated from DB on change and reloaded with wg syncconf wg0 <(wg-quick strip wg0) — this applies config diff without dropping tunnels
  • Monitoring: WireGuard exporter → VictoriaMetrics → Grafana, alert on stale handshakes
  • Failover: floating IP via hetzner’s API, secondary hub mirrors config from primary via rsync + inotify

The network itself uses about 15% of the hub’s CPU at peak with 200 active peers doing normal workloads. The bottleneck is always bandwidth before CPU at that scale.

WireGuard’s kernel-space implementation means you’re not paying Python or userspace overhead per packet. The architecture holds up well past 500 peers on modest hardware — as long as you got the IP design right on day one.

👁 Views: 202,912 · Unique visitors: 57,425