Originally published on kuryzhev.cloud
When you face this choice
Rotate a WireGuard key wrong and your tunnel doesn't error out — it just goes quiet. No log line, no alert, nothing. You spend an hour debugging "the network" before realizing the handshake never happened, because the peer on the other end is still holding the old public key. That's usually the moment WireGuard key rotation stops being a checkbox in a compliance doc and becomes a real operational problem.
The trigger is almost always the same story. You started with two or three hand-edited wg0.conf files, maybe checked into a private repo, and it worked fine for months. Then peer count climbed past 15-20 — contractors joining, ephemeral CI runners spinning up, autoscaled nodes coming and going — and suddenly every rotation is a fan-out exercise across machines you don't all remember exist.
Here's the thing people get wrong up front: the decision isn't "WireGuard or something else." WireGuard is staying either way — it's fast, it's auditable at the protocol level, and the Noise-based handshake is genuinely solid. The real decision is who owns peer state and rekeying: your git repo plus a config management tool, or a coordination service that pushes live state to every node.
The failure mode that forces this decision is almost always the same: a rotation that "worked" on the box you were staring at, but left two or three peers with stale keys because nobody had a reliable list of who needed the update. No alarm fires. You just get silent, un-alarmed packet loss until someone notices a service timing out.
Option A: Static configs + GitOps rotation
This is the approach most teams start with, and for good reason — it's simple to reason about. Peer definitions and keypairs live in version control, applied via Ansible, Salt, or Terraform, and pushed with wg syncconf so you get a zero-downtime reload instead of tearing the interface down.
# Generate a keypair — never commit the private key in plaintext
wg genkey | tee privatekey | wg pubkey > publickey
# Apply a new config without dropping existing sessions
wg syncconf wg0 <(wg-quick strip wg0)
Pros: everything is auditable through git history — you can point to a commit and say exactly when a key rotated and who approved it. There's no third-party trust anchor to compromise. It works fine in air-gapped or regulated environments where "call an external API to manage network identity" is a non-starter. And there's no extra infrastructure to patch, back up, or secure.
The problem is rotation itself is a fan-out problem. Every peer needs the new public key before the old one is dropped, and that coordination scales linearly (badly) with peer count. Race conditions creep in — someone reruns the playbook against a subset of hosts, someone forgets a peer that's technically still in inventory but not tagged right, and you're back to silent packet loss. There's also no built-in discovery or ACL layer; you're managing AllowedIPs by hand, which gets error-prone past a few dozen entries.
I still like this option a lot for small, stable meshes. It's the boring choice, and boring is good in network identity management.
Option B: Dynamic control-plane / mesh orchestrator
The alternative is a coordination layer — something in the headscale/Netmaker family, or a custom control API — that holds peer identity and state centrally and pushes configuration out, including automatic key or PSK rotation on a schedule.
The pitch is obvious: automatic peer discovery, real ACLs instead of hand-maintained AllowedIPs, and live rekeying without touching every node by hand. Onboarding a new contractor or scaling out a fleet of autoscaled workers stops being a git PR and manual apply — it's a policy the control plane enforces continuously. This scales cleanly into the hundreds of peers, which static configs frankly don't.
The catch — and it's a real one — is that the control plane becomes a trust and availability dependency. Existing WireGuard tunnels don't need a heartbeat to a server to keep working (that's a nice property of the protocol), but new joins and new rotations absolutely do. If your control plane is down during a scheduled rotation window, that rotation just doesn't happen, and you need a runbook that accounts for that distinction explicitly — "tunnels stay up" is not the same as "rotation succeeded."
You're also adding attack surface: a service that can push identity changes to your entire mesh is a very attractive target. There's tool lock-in to consider, and a real learning curve for whoever ends up owning that service in production. SaaS-hosted control planes are frequently disallowed outright in regulated or air-gapped environments, which pushes serious teams toward self-hosting the coordination layer if they want the automation at all.
Decision matrix
Here's the version I actually use when a client asks me to make this call. Weigh peer count and churn, required rotation cadence, regulatory constraints, and whether the team has the appetite to run and secure another piece of infrastructure.
Criterion | Static config + GitOps | Control-plane orchestrator
------------------------------|------------------------------|-----------------------------
Peer count | Comfortable < 20-30 | Scales to 100s cleanly
Peer churn rate | Low (stable topology) | High (autoscale, contractors)
Rotation cadence | Manual/scripted, quarterly+ | Automatic, can go monthly
Air-gapped / regulated env | Strong fit | Needs self-hosted variant
Ops team maturity needed | Low-medium (git + Ansible) | Medium-high (own the control plane)
Extra infra to secure | None | Yes (control plane HA + auth)
Audit trail | Git history = built-in | Depends on tool's logging
Failure blast radius | Push errors only, no SPOF | Control plane outage blocks new joins/rotations
Somewhere in the 10-30 stable peers with quarterly rotation range, it's genuinely a toss-up — pick whichever your team already knows how to operate well. Below that, static configs win clearly. Above it, or with high churn, the control plane starts paying for itself fast.
One pattern I've seen work well as a middle ground: run the control plane for day-to-day onboarding/offboarding, but keep a git-based source of truth as an audit trail and disaster-recovery fallback if the control plane itself gets wiped out.
My pick
Under roughly 20 peers with low churn and any regulatory pressure at all: static config plus GitOps rotation, full stop. The automation a control plane gives you isn't worth the added trust surface at that scale — you're trading a manageable manual process for a new system you now have to secure, patch, and keep highly available.
Past that scale, or with frequent peer churn from autoscaling and contractor access, I go with a self-hosted control plane every time, even accounting for the extra component to run. The operational sanity of automatic rekeying and real ACLs outweighs the added surface, and honestly, doing large-scale WireGuard key rotation manually is where most of the silent-failure incidents I've cleaned up actually came from.
Regardless of which camp you're in, a few things aren't optional. Rotate on a schedule — 30 to 90 days is the standard that survives audit review in 2026, anything longer tends to get flagged. Use a proper rotation script with an overlap window instead of dropping the old key immediately:
#!/usr/bin/env bash
# rotate-peer-key.sh — zero-downtime key rotation for one WireGuard peer
set -euo pipefail
IFACE="wg0"
PEER_NAME="$1"
OLD_PUBKEY=$(vault kv get -field=pubkey "wg/peers/${PEER_NAME}")
# Generate new keypair — private key never touches disk unencrypted
NEW_PRIVKEY=$(wg genkey)
NEW_PUBKEY=$(echo "$NEW_PRIVKEY" | wg pubkey)
# Push new keypair to Vault before touching any live config
vault kv put "wg/peers/${PEER_NAME}" \
privkey="$NEW_PRIVKEY" pubkey="$NEW_PUBKEY"
# Overlap window: both old and new keys are valid at once
wg set "$IFACE" peer "$NEW_PUBKEY" allowed-ips "10.10.0.7/32" persistent-keepalive 25
# Wait for a confirmed handshake on the NEW key before removing the OLD one
for i in {1..30}; do
if wg show "$IFACE" dump | grep -q "$NEW_PUBKEY"; then
echo "New key handshake confirmed for ${PEER_NAME}"
break
fi
sleep 2
done
# Safe cutover: only remove the old peer once the new one is live
wg set "$IFACE" peer "$OLD_PUBKEY" remove
echo "Rotation complete for ${PEER_NAME}: ${OLD_PUBKEY} -> ${NEW_PUBKEY}"
Two gotchas I've hit personally: dropping the old key immediately instead of running an overlap window kills in-flight sessions mid-rotation, and rotating the key while forgetting the firewall rules or monitoring tied to the old peer identity breaks alerting quietly — the exact opposite of what you want from a security control. Also stagger rotations across a large mesh; forcing simultaneous rekeys causes a handshake storm and real CPU spikes on hub or relay nodes.
Last thing: never treat "no error" as proof a rotation succeeded. Verify with wg show wg0 dump and check last-handshake timestamps before and after. Store keys the way you'd store TLS private keys — in Vault or SOPS-encrypted secrets, injected at runtime, with access logged. Rotate preshared keys on their own, often shorter, cadence as a cheap second defense layer. If you're weighing this against broader infra automation choices, it's worth reading our notes on infrastructure automation tradeoffs before committing either way.
For the protocol-level details on handshakes and key exchange, the official WireGuard protocol documentation is worth a re-read, and if you're going the control-plane route, check the headscale documentation before you commit to self-hosting one in production.
Top comments (0)