A WireGuard mesh you can actually reason about
The lab's estate runs on ten WireGuard peers sharing one flat address plan. There is no control plane, no coordination server, no enrolment daemon, no web console. Peers are declared in config files and that is the whole system. Several people have told me this does not scale. They are right, and it does not matter — because the property I am buying is not scale.
The property I am buying
I want to be able to answer, from a cold start at three in the morning, exactly which machines can reach which other machines and why. Not "the overlay says they're connected" — the actual reason, readable in a file, with no service that needs to be up for the answer to be true.
WireGuard is unusually good at this because it has almost no state to be confused
about. There is no session negotiation to be halfway through, no connection to be
half-open. A peer is a public key, an allowed IP range and possibly an endpoint.
wg show prints the truth in twenty lines. Compared to almost anything
else in networking, that is a rounding error of complexity.
One address plan, decided once
Pick the range before the second peer exists, and write down what each block means. Ours is a single /24 with fixed roles: low addresses for permanent servers, a block for laptops and phones, a block reserved for things not built yet. Addresses are assigned by hand and never recycled.
Never recycling is the part that pays. A mesh IP becomes a permanent name for a
machine, so 10.0.0.3 in an nginx proxy_pass written two
years ago still means what it meant when it was written. Recycling addresses
turns every old config file and every firewall rule into a small trap.
Two other decisions I would make again without hesitating:
-
Everything binds to loopback or a mesh address. Nothing but
nginx listens on a public interface. The application servers behind it —
Node, PHP-FPM, the Next.js apps, the databases — have no public presence at all.
Not firewalled off; genuinely not listening. A firewall rule is a thing that can
be wrong. A service bound to
127.0.0.1is not reachable because there is nowhere for the packet to arrive. - Internal traffic uses mesh addresses, never public DNS. Two services on the same estate talking to each other over the public internet, out through the edge and back in, is a failure mode that works — which is what makes it insidious. It only reveals itself as inexplicable latency and a TLS termination you did not intend.
What I deliberately left out
A mesh coordinator. Tailscale, Headscale, Netmaker and friends are good software solving a real problem — key distribution and NAT traversal at a scale where hand-editing configs stops being sane. At ten peers I am nowhere near that scale, and the coordinator would become the thing most likely to break: a component whose failure partitions a network that would otherwise have kept running, added in exchange for saving me an occasional five-minute config edit.
Dynamic routing. No BGP, no OSPF. The routes are static because the topology is static. A protocol that recomputes reachability is only worth its complexity if reachability actually changes.
Full mesh. Not every peer needs a tunnel to every other peer. Ten peers fully meshed is forty-five tunnels to keep straight; hub-and-spoke with a couple of direct links where latency actually matters is about twelve, and I can hold all twelve in my head.
Two mistakes, an afternoon each
AllowedIPs is a routing table, not an ACL
This is the WireGuard misconception, and it is worth stating plainly because the
name actively misleads. AllowedIPs does two jobs at once. Inbound, it
is a filter: packets from this peer claiming a source outside its
AllowedIPs are dropped. Outbound, it is a route: traffic for these
destinations goes to this peer.
The consequence is that AllowedIPs must be disjoint across peers. Two
peers listing overlapping ranges is not a permissions conflict, it is an ambiguous
routing table, and WireGuard resolves it by longest-prefix match — silently. I
spent an afternoon on traffic vanishing into a peer that had a broader range than
I remembered giving it. wg show was showing me the answer the entire
time; I was reading it as a permissions list.
PersistentKeepalive is not optional behind NAT
WireGuard is silent when idle — genuinely silent, no keepalives, by design. A peer behind NAT that has not sent anything has no mapping in its NAT table, so nothing can reach it until it speaks first. The tunnel appears fine from the NATed side and dead from the other, which is a properly confusing symptom.
PersistentKeepalive = 25 on any peer behind NAT. Twenty-five seconds
because most NAT tables expire UDP mappings at thirty. Servers with public
endpoints do not need it and should not have it — silence is the feature there.
When this stops being the right answer
Somewhere around twenty-five or thirty peers, or as soon as more than one person needs to add a peer without coordinating, hand-managed configs become the bottleneck and a coordinator starts earning its complexity. Any peer set that changes weekly wants automation regardless of size.
Until then, the ability to explain the entire network from files that do not depend on anything being up is worth more than the convenience I am giving up. A network you can reason about completely is a different kind of object from one you merely observe.