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

by

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 = 25

Note 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=1

Make 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:

  • direct and multihop 2 are 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 2 allows the session to work even if the kernel route table doesn’t have a direct route.
  • next hop self ensures 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 = 1420

Also 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 bird

Verify the Wireguard handshake:

wg show

Check BIRD neighbors:

birdc show protocols all

You should see both BGP sessions established. Then check routes:

birdc show route

Each 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:

eyJsYW5nIjoiY29uZiIsInRleHQiOiJwcm90b2NvbCBiZ3AgcGVlcl9jb250YWJvIGZyb20gbWVzaF9wZWVyIHtcbiAgICBuZWlnaGJvciAxMC4wLjAuMiBhcyA2NDUxMztcbiAgICBob2xkIHRpbWUgOTtcbiAgICBrZWVwYWxpdmUgdGltZSAzO1xufSJ9

With 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

  1. 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.

  2. 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.

  3. PersistentKeepalive matters. Some providers (Contabo especially) have aggressive NAT timeouts. Without keepalive, the tunnel drops after ~2 minutes of idle.

  4. 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.

  5. Monitoring is essential. Use wg show and birdc in 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.

#bare-metal#bgp#networking#self-hosted#sovereign-infrastructure#wireguard
Share — X / Twitter · LinkedIn · HN · Email
Damir Radulić
Founder of RiNET. On the Croatian internet since 1996 (Kvarner Net). In Amsterdam now, building autonomous AI infrastructure that runs on Monday morning when nobody's watching — sovereign stacks, agent swarms, LoRA fine-tuning, civic-intelligence platforms.