Wireguard Mesh Routing Across Three EU Datacenters: Lessons in Tailscale-Free Sovereign Networking
Building a flat, encrypted L3 network across Hetzner, Contabo, and OVHcloud with pure Wireguard and BIRD
Wireguard Mesh Routing Across Three EU Datacenters: Lessons in Tailscale-Free Sovereign Networking
When you control your own networking, you own your latency, your routing decisions, and your privacy. Tailscale is convenient, but it funnels traffic through DERP relays and ties you to a third-party control plane. For a self-hosted AI stack spread across three EU datacenters—Hetzner (Falkenstein), Contabo (Munich), and OVHcloud (Gravelines)—I wanted a flat, encrypted L3 network with no single point of failure and no external dependency.
This article walks through the Wireguard full-mesh design, BIRD-based BGP routing, kernel tuning, and the failure modes I encountered. All configs are reproducible. Versions: Wireguard 1.0.20220627 (kernel module), BIRD 2.14, Debian 12, Linux 6.1.
Why Not Tailscale?
Tailscale is built on Wireguard, but it adds a coordination server (even if self-hosted via Headscale). That server becomes a trust anchor. If you want to route traffic dynamically—say, fail over from one datacenter to another when a node goes dark—Tailscale’s ACL-based subnet routing is brittle. You can’t run BGP over it. You can’t control path selection. And DERP relays add unnecessary latency for inter-datacenter traffic.
For sovereign infrastructure, every packet should stay on hardware you control, routed by software you configure.
Topology
Three nodes, each with a public IPv4 and a /64 IPv6 from their provider. Private Wireguard IPs in 10.0.0.0/24.
| Node | Location | Public IP | Wireguard IP |
|---|---|---|---|
| hetzner | Falkenstein | 1.2.3.4 | 10.0.0.1/32 |
| contabo | Munich | 5.6.7.8 | 10.0.0.2/32 |
| ovh | Gravelines | 9.10.11.12 | 10.0.0.3/32 |
Each node also announces a /32 loopback (e.g., 10.0.1.x) for service IPs that can move between nodes. BGP carries these prefixes.
Wireguard Configuration
A full mesh means each node has a peer entry for every other node. Here’s the config for hetzner:
[Interface]
Address = 10.0.0.1/32
ListenPort = 51820
PrivateKey = <hetzner_private>
# contabo
[Peer]
PublicKey = <contabo_public>
AllowedIPs = 10.0.0.2/32, 10.0.1.2/32
Endpoint = 5.6.7.8:51820
PersistentKeepalive = 25
# ovh
[Peer]
PublicKey = <ovh_public>
AllowedIPs = 10.0.0.3/32, 10.0.1.3/32
Endpoint = 9.10.11.12:51820
PersistentKeepalive = 25Note AllowedIPs includes both the Wireguard transport IP and the loopback service IP. This is critical: Wireguard’s crypto routing only processes packets destined for IPs in AllowedIPs. Without the loopback, BGP updates from that peer would be dropped.
Enable IP forwarding and disable reverse path filtering on the Wireguard interface:
sysctl -w net.ipv4.ip_forward=1
sysctl -w net.ipv4.conf.wg0.rp_filter=2
sysctl -w net.ipv6.conf.all.forwarding=1Make persistent in /etc/sysctl.d/90-wireguard.conf.
BIRD BGP Configuration
BIRD runs on each node, peering over the Wireguard interface. We use eBGP with private ASNs (64512-64514) and next-hop-self to avoid recursive routing issues.
Here’s /etc/bird/bird.conf for hetzner:
eyJsYW5nIjoiY29uZiIsInRleHQiOiJyb3V0ZXIgaWQgMTAuMC4wLjE7XG5cbnByb3RvY29sIGRldmljZSB7fVxuXG5wcm90b2NvbCBrZXJuZWwge1xuICAgIGlwdjQge1xuICAgICAgICBleHBvcnQgYWxsO1xuICAgICAgICBpbXBvcnQgbm9uZTtcbiAgICB9O1xuICAgIGxlYXJuIG9mZjtcbn1cblxucHJvdG9jb2wgc3RhdGljIHtcbiAgICBpcHY0O1xuICAgIHJvdXRlIDEwLjAuMS4xLzMyIHZpYSAxMC4wLjAuMTsgICMgbG9jYWwgbG9vcGJhY2tcbn1cblxuZmlsdGVyIGV4cG9ydF9sb29wYmFja3Mge1xuICAgIGlmIG5ldCB+IFsgMTAuMC4xLjAvMjQgXSB0aGVuIGFjY2VwdDtcbiAgICByZWplY3Q7XG59XG5cbnRlbXBsYXRlIGJncCBtZXNoX3BlZXIge1xuICAgIGxvY2FsIGFzIDY0NTEyO1xuICAgIHNvdXJjZSBhZGRyZXNzIDEwLjAuMC4xO1xuICAgIGlwdjQge1xuICAgICAgICBpbXBvcnQgYWxsO1xuICAgICAgICBleHBvcnQgZmlsdGVyIGV4cG9ydF9sb29wYmFja3M7XG4gICAgICAgIG5leHQgaG9wIHNlbGY7XG4gICAgfTtcbiAgICBkaXJlY3Q7XG4gICAgbXVsdGlob3AgMjtcbn1cblxucHJvdG9jb2wgYmdwIHBlZXJfY29udGFibyBmcm9tIG1lc2hfcGVlciB7XG4gICAgbmVpZ2hib3IgMTAuMC4wLjIgYXMgNjQ1MTM7XG59XG5cbnByb3RvY29sIGJncCBwZWVyX292aCBmcm9tIG1lc2hfcGVlciB7XG4gICAgbmVpZ2hib3IgMTAuMC4wLjMgYXMgNjQ1MTQ7XG59In0=Key points:
directandmultihop 2are needed because the BGP session runs over the Wireguard interface, which is a point-to-point link but not directly connected in the traditional sense.multihop 2allows the session to work even if the kernel route table doesn’t have a direct route.next hop selfensures that when a node advertises a loopback, it sets itself as the next hop, so other nodes don’t try to reach the loopback via an intermediate node.- The static route for the local loopback ensures it’s present in the BIRD table.
Firewall and MTU
Wireguard adds 60 bytes of overhead. Standard Ethernet MTU is 1500, so set the Wireguard interface MTU to 1420. In the [Interface] section:
MTU = 1420Also open UDP port 51820 on each node’s public firewall. For Hetzner, that means the robot firewall; for OVH, the vRack or IP block firewall; for Contabo, iptables.
Bringing It Up
Start Wireguard and BIRD:
systemctl enable --now wg-quick@wg0
systemctl enable --now birdVerify the Wireguard handshake:
wg showCheck BIRD neighbors:
birdc show protocols allYou should see both BGP sessions established. Then check routes:
birdc show routeEach node should see the two remote loopbacks (e.g., 10.0.1.2 and 10.0.1.3).
Testing Failure Recovery
Simulate a link failure by stopping Wireguard on one node. BIRD will detect the BGP session drop after the hold timer (default 240 seconds). To speed this up, lower the hold time:
eyJsYW5nIjoiY29uZiIsInRleHQiOiJwcm90b2NvbCBiZ3AgcGVlcl9jb250YWJvIGZyb20gbWVzaF9wZWVyIHtcbiAgICBuZWlnaGJvciAxMC4wLjAuMiBhcyA2NDUxMztcbiAgICBob2xkIHRpbWUgOTtcbiAgICBrZWVwYWxpdmUgdGltZSAzO1xufSJ9With a 9-second hold, failure detection takes ~9 seconds. In practice, this is fast enough for most workloads. If you need sub-second, consider BFD (Bidirectional Forwarding Detection), but that adds complexity.
When contabo goes down, hetzner and ovh will remove the route to 10.0.1.2. If you have a service IP on that loopback, traffic to it will blackhole. To achieve actual failover, you’d need to move the service IP to another node (e.g., via keepalived or a health check that updates BIRD). That’s a separate topic.
One lesson: Wireguard’s persistent keepalive (25s) prevents NAT/firewall timeouts but does not trigger fast failure detection. The BGP hold timer is your primary failure detector.
Performance Tuning
Wireguard performs well out of the box, but a few tweaks help on high-bandwidth links:
- Increase kernel UDP buffer sizes:
sysctl -w net.core.rmem_default=262144
sysctl -w net.core.rmem_max=4194304
sysctl -w net.core.wmem_default=262144
sysctl -w net.core.wmem_max=4194304- Disable GRO/GSO on the Wireguard interface if you see packet drops (some drivers have issues):
echo 'off' > /sys/class/net/wg0/features/gro
echo 'off' > /sys/class/net/wg0/features/gso- Use the kernel module (not userspace) for best throughput. Debian 12 ships it built-in.
Lessons Learned
AllowedIPs is not a firewall. It’s a routing table. If you set
AllowedIPs = 0.0.0.0/0, Wireguard will route all traffic through that peer. Use specific prefixes.BGP next-hop-self is mandatory in a mesh where the Wireguard interface is not the same as the physical interface. Without it, BIRD will try to use the public IP as the next hop, which fails because that IP is not in
AllowedIPs.PersistentKeepalive matters. Some providers (Contabo especially) have aggressive NAT timeouts. Without keepalive, the tunnel drops after ~2 minutes of idle.
IPv6 is simpler. If all your providers give you a /64, you can avoid NAT entirely. But OVHcloud’s IPv6 setup requires an additional tunnel (6in4) for older instances. I stuck with IPv4 for simplicity.
Monitoring is essential. Use
wg showandbirdcin your monitoring stack (e.g., Prometheus node_exporter textfile collector). Alert on handshake age > 2 minutes or BGP session down.
Alternatives and Extensions
If you need to scale beyond 10 nodes, full-mesh Wireguard becomes unwieldy. At that point, consider a hub-and-spoke with a central route reflector running BIRD, or use a mesh VPN like ZeroTier (which still has a control plane) or Netmaker (self-hosted). But for three nodes, full-mesh is simple and robust.
For dynamic failover of service IPs, combine BIRD with a health check script that withdraws/advertises routes via birdc. Or use keepalived with VRRP over the Wireguard interface.
Conclusion
A Wireguard mesh with BIRD BGP gives you sovereign, encrypted, and dynamically routed networking across datacenters. It’s not as turnkey as Tailscale, but it’s entirely under your control. The setup is reproducible, the failure modes are predictable, and the performance is excellent. For a self-hosted AI stack, this is the foundation—fast, private, and independent.