WireGuard mesh clusters#
This is the authoritative, end-to-end guide to running a Spur cluster over an encrypted WireGuard mesh: mesh bring-up, why peer endpoints matter for worker-to-worker connectivity, online node removal, HA over the mesh, and bringing up a SPUR-managed k0s cluster inside the meshed cluster.
The Deploying with Ansible (recommended) and Manual Deployment (systemd) pages show the mechanics (variables and by-hand commands respectively); this page is the conceptual reference they link to. When a mesh detail matters for correctness, it is explained here.
Why a mesh (and why endpoints matter)#
With [network] wg_enabled = true every node gets a stable address on the mesh
CIDR (default 10.44.0.0/16): the first controller is .1, and each other
node is .2, .3, … All control-plane traffic (scheduler, Raft, agent
heartbeats) and — when k0s runs over the mesh — pod traffic ride the encrypted
spur0 interface instead of the underlay LAN.
WireGuard is a cryptokey router: a peer entry needs both an AllowedIPs set
(which mesh/pod addresses route to that peer) and an endpoint (the peer’s real
underlay host:port to send packets to). spur net join establishes exactly
one tunnel — worker→controller — so a plain join/add-peer flow yields a
hub-and-spoke: every node can reach the controller, but two workers have each
other’s AllowedIPs with no endpoint, so worker↔worker packets are dropped.
A full mesh therefore requires each peer to be advertised with its underlay endpoint. Two mechanisms supply this:
spur net add-peer --endpoint <host:port>— register one peer with its underlay endpoint (not its mesh IP, which is circular).spur net mesh --config <membership.json> --self <mesh-ip>— apply a full mesh from a shared membership document on every node, wiring all remaining node↔node tunnels (including controller↔controller) in one pass.
Under a SPUR-managed k0s cluster the controller’s reconcile loop maintains this
automatically; for a pure-scheduler mesh (no k0s) the spur net mesh pass is
what converges the cluster to all-to-all.
Mesh bring-up#
On the bootstrap controller (assigned .1):
sudo spur net init --cidr 10.44.0.0/16 --port 51820 --interface spur0
wg show spur0 public-key # the server key workers need to join
On each other node (controllers included, for HA), join the mesh and read back its own public key:
sudo spur net join \
--endpoint <bootstrap-underlay>:51820 \
--server-key <bootstrap-pubkey> \
--address 10.44.0.2 \
--prefix-len 16 \
--interface spur0
wg show spur0 public-key
Then, on the bootstrap controller, register each joiner with its underlay endpoint so worker↔worker tunnels can form (not just hub-and-spoke):
sudo spur net add-peer \
--key <joiner-pubkey> \
--allowed-ip 10.44.0.2/32 \
--endpoint <joiner-underlay>:51820 \
--interface spur0
Verify all-to-all reachability over the mesh IPs (every node should reach every other, not just the controller):
spur net status # peers + handshake times
ping -c1 10.44.0.3 # from a worker, to another worker's mesh IP
Boot persistence#
spur net init / join bring the interface up but do not enable it for boot.
Enable the wg-quick@<iface> unit so the interface is recreated on reboot from
/etc/wireguard/<iface>.conf:
sudo systemctl enable wg-quick@spur0
The Ansible toolkit does this automatically when spur_wg_persist=true (the
default).
add-peer, remove-peer, and mesh also persist their result to
/etc/wireguard/<iface>.conf (in addition to applying it live), so a peer
added this way survives the interface being recreated on reboot. Point
--config-dir at the same directory used for init/join if it isn’t
the default /etc/wireguard.
Rewriting the file preserves directives Spur does not manage itself — PostUp,
MTU, Table, per-peer PresharedKey and so on are carried through
unchanged. Comments and blank lines are not preserved. Repeated Address or
AllowedIPs lines keep every value but are rewritten as the equivalent single
comma-separated line, which wg and wg-quick treat identically.
Under a SPUR-managed k0s cluster, the peers in a k0s-meshed node’s persisted
config are also protected from the k0s reconcile loop’s prune pass (see below):
the reconcile only removes a peer that is both outside its own k0s membership
and absent from that node’s config, so a peer you added for something outside
the k0s cluster is never pruned out from under you. Note this covers every peer
in the file, including ones a previous spur net mesh wrote — so if you ran
mesh before enabling k0s, that membership is pinned too.
If spurd runs with a non-default config directory, set
SPUR_WG_CONFIG_DIR in its environment to match the --config-dir you pass
to spur net. spurd reads the persisted config from that directory to
decide what to protect, and defaults to /etc/wireguard; if the two disagree,
it finds no config and protects nothing.
Removing a node from the mesh#
Important
Deregister the node from the cluster first, then drop its mesh peer — not
the other way around. spur net remove-peer is a purely local wg
mutation; it does not touch cluster state. Under a SPUR-managed k0s cluster the
controller’s reconcile loop rebuilds mesh membership from live node inventory
every ~30s, so if the node is still registered it will simply re-push the peer
you just removed. Run spur node remove <node> (or the equivalent) first, so
the reconcile no longer includes it, then remove the peer.
When a node leaves, drop its peer entry so it does not linger as a “ghost” peer (and, on the node itself, tear the interface down so it does not rejoin on reboot):
# 1. Deregister from the cluster so the reconcile stops advertising it:
sudo spur node remove <departed-node>
# 2. On the controller — drop the departed node's peer (idempotent):
sudo spur net remove-peer --key <departed-node-pubkey> --interface spur0
# 3. On the departed node — stop and de-persist the interface:
sudo systemctl disable --now wg-quick@spur0
sudo rm -f /etc/wireguard/spur0.conf
In an HA mesh, remove the peer on every controller, not just the bootstrap — otherwise the others keep a stale peer.
Note
spur net add-peer --program-routes (used only on the no-CNI bare-mesh test
path) installs a kernel route for the peer’s pod CIDR. remove-peer does not
remove that route, so on a bare-mesh setup drop it by hand
(ip route del <pod-cidr> dev spur0). With a CNI (the normal case) the CNI
owns the routes and this does not apply.
Note
The Ansible remove_nodes.yml playbook automates all of the above — the
controller-side remove-peer (on all controllers) and the node-side teardown
— but that WireGuard cleanup ships in spur-toolkit#23, which is not yet merged. Until
it lands, perform these steps manually.
High availability over the mesh#
HA (3 or 5 controllers with Raft) is supported over WireGuard. All controllers join the same mesh; because Raft elections require the controllers to reach each other directly, the full-mesh pass must wire controller↔controller tunnels — a plain hub-and-spoke would leave the non-bootstrap controllers unable to elect a leader if the bootstrap goes down.
Point every controller’s hosts / peers at the mesh IPs (not underlay
addresses), in the same order on every controller (node_id is the 1-based
position):
[controller]
hosts = ["10.44.0.1", "10.44.0.3", "10.44.0.4"]
peers = [
"10.44.0.1:6821",
"10.44.0.3:6821",
"10.44.0.4:6821",
]
Raft elects a leader automatically; clients and agents may target any controller and are redirected to the current leader. See Manual Deployment (systemd) for the full per-node controller/agent setup. An inventory-driven, multi-controller WireGuard-mesh flow is added by spur-toolkit#23 (not yet merged — until it lands the Ansible role supports a single controller under WireGuard only).
k0s cluster inside the mesh#
Once the mesh is up, a SPUR-managed k0s cluster can run pod traffic over it. Set
[cluster] enabled = true (or spur_k8s_enabled=true in Ansible) with Calico
bird native routing so pods ride the mesh:
pod_cidr(default10.42.0.0/16) andservice_cidr(10.43.0.0/16) are carved per node; each node’s pod/24is folded into its peer’sAllowedIPsso cross-node pod traffic routes overspur0.The k8s API is advertised on the control-plane’s mesh IP.
The controller reconcile converges the full mesh (endpoints included) as part of
spur k8s up, so a k0s-over-mesh cluster does not need the manual net mesh
pass. See Spur-Managed Kubernetes (k0s) for running Spur inside an existing Kubernetes
cluster.
Note
The Ansible spur_k8s_* variables and the k8s_up.yml /
k8s_add_nodes.yml playbooks referenced for this flow ship in spur-toolkit#23, which is not yet merged. Until
it lands, drive spur k8s up / add-nodes directly (as shown above) rather
than via those playbooks.