WireGuard mesh between multiple VPSes — Tailscale alternative with wg-quick
Tailscale handles WireGuard mesh networking transparently — every node sees every other node, NAT punching is automatic, you barely think about it. Self-hosting the same shape with plain wireguard + wg-quick is more work, but gives you full ownership and no third-party identity dependency. This article covers the pattern for a small fleet of LYLIX VPSes.
The topology
Full mesh (each node connects to every other) is the right answer for small fleets up to ~10 nodes. Hub-and-spoke (one central VPS, all others connect to it) scales further but adds a single-point-of-failure. For most "I have 3-5 VPSes and want them on a private network" cases, full mesh is simpler.
Address plan
Pick a private /24 (or /16 for room to grow):
- 10.99.0.0/24 — the mesh network.
- Each node gets a /32 in that range:
10.99.0.1,10.99.0.2, etc. - Document the IP-to-hostname mapping somewhere durable (a shared note in your password manager works).
Per-node setup
On each node, generate its keypair (one-time):
cd /etc/wireguard
umask 0077
wg genkey | tee node-private.key | wg pubkey > node-public.key
cat node-public.key # copy this to add to every OTHER node's config
Config at /etc/wireguard/wg0.conf on node 1:
[Interface]
PrivateKey = <contents of node-private.key on node 1>
Address = 10.99.0.1/24
ListenPort = 51820
[Peer]
# node 2
PublicKey = <node 2's public key>
Endpoint = node2.example.com:51820
AllowedIPs = 10.99.0.2/32
PersistentKeepalive = 25
[Peer]
# node 3
PublicKey = <node 3's public key>
Endpoint = node3.example.com:51820
AllowedIPs = 10.99.0.3/32
PersistentKeepalive = 25
Repeat on node 2 (with its own private key, address 10.99.0.2, and peers for nodes 1 and 3), and so on.
systemctl enable --now wg-quick@wg0
wg show # verify handshake with every peer
The verbose part: scaling the config
N nodes mean each node's config has N-1 peers, and adding a new node means editing all N existing configs. Tolerable at 5; painful at 15. Mitigations:
- Config-mgmt tool (Ansible, NixOS, plain shell scripts) that generates each node's config from a single inventory file.
- Switch to Headscale — open-source Tailscale-control-plane reimplementation. Adds a coordination service but eliminates the per-node config maintenance. See the existing Headscale article in this category.
DNS for mesh hosts
Decide how nodes refer to each other. Options:
- /etc/hosts on every node — small fleet, manual sync. Easy to start with.
- Internal DNS server — one node runs BIND or dnsmasq listening on the WG interface; others use it as resolver.
- Magic DNS via Headscale / Tailscale — names resolve automatically. Easiest if you've switched off plain WireGuard.
Firewall
Open UDP 51820 on each node's public firewall (nftables/ufw/firewalld). Restrict if possible to known peer IPs:
# nftables snippet — only accept WG from the IPs of your other nodes
udp dport 51820 ip saddr { 192.0.2.11, 192.0.2.12, 192.0.2.13 } accept
Block 51820 from everyone else (the default deny rule handles this if you have one).
Routing services over the mesh
Bind internal services to the WG IP only:
# PostgreSQL listening on the mesh IP — not exposed publicly
# /etc/postgresql/15/main/postgresql.conf
listen_addresses = 'localhost, 10.99.0.1'
Same pattern for any service you want mesh-reachable but not public. The public IP can drop all inbound except 22, 80/443, 51820.
When to give up and run Headscale
If you're touching wg0.conf on multiple nodes more than once a month, switch to Headscale. The control-plane reimplementation eliminates the N×N config maintenance. See the dedicated Headscale article — same effect, much less manual work past 5 nodes.
Also Read
Powered by WHMCompleteSolution