Blog

Headscale: Self-Hosting the Tailscale Control Plane

Headscale nodes forming a self-hosted mesh on a home network

Once the node count creeps up, the hand-written WireGuard peer matrix grows exponentially. Tailscale's experience is excellent; when I wanted the control plane in my own hands, I picked Headscale. This post isn't an install click-through — it's about how I actually thought through the access control lists, step by step. Because what locks you out isn't the service failing to start; it's writing a policy wrong and stranding yourself in a coffee shop with your phone unable to reach the admin interface.

First ask: what am I isolating?

Run through the asset inventory:

  • NAS admin interface (web UI and SSH)
  • Virtualization platform and router admin
  • Game server ports (friends need them, but they don't need to touch infrastructure)
  • Traveling nodes like the laptop and phone
  • Automation users (backups, monitoring scraping)

A "flat Layer-2 VPN where anyone connected can scan the whole network" is exactly what I don't want. The goal is closer to this: identity plus tags decide reachability, instead of "once you're in the tunnel, you're home." Zero trust doesn't need to be shouted loudly in a homelab, but the policy should dare to say "guests never touch the storage admin interface."

Phase one: allow everything, just to prove the control plane is alive

For the first two weeks after going live, access control was basically wide open. The reason is pragmatic: if I'm simultaneously debugging whether relays work, whether name resolution is sane, and whether some Android client registers — and at the same time debugging "is my policy too strict" — the problems tangle together and nobody can say what caused what.

This phase's checklist has only three items: nodes can register and reach each other; the control plane database has a backup job; and an out-of-band path still works — a physical console, a cloud vendor's remote desktop, or an emergency tunnel that doesn't route through Headscale.

No out-of-band, no tightening. That's the first line of my ops manual. Tightening is a privileged operation; you have to assume you'll get it wrong.

Phase two: tags beat usernames

At first I wrote rules by "user" and quickly hit the problem: the same user has both a trusted laptop and a throwaway experiment box, and a friend's account should only ever touch game servers. So I switched to thinking in tags:

  • Admin-device tag: my daily terminal
  • Infrastructure tag: storage, virtualization, gateway
  • Game tag: Palworld, Factorio, and the like
  • Guest tag: friends' devices
  • Automation tag: backups and monitoring

In policy, letting the guest tag reach only the specified ports on the game tag is far clearer than "Zhang San can access the entire internal subnet." Write tags into the docs as you apply them: new machines start with an untrusted tag until manually promoted. Tags are the policy's vocabulary — if the vocabulary is a mess, longer rules just get wrongly complicated.

Phase three: three times tightening almost locked me out

First time: I let the admin interface accept only the admin-device tag — but my phone client hadn't been tagged yet, so after I applied the policy from outside, I could only stare at it. Saved by out-of-band remote desktop. Rule from now on: before any tightening, confirm your current exit device is correctly tagged.

Second time: I made DNS resolution depend on the mesh too, and when the network dropped, resolution failed and even the control plane domain wouldn't resolve. Fix: keep the control plane domain on public DNS, or hardcode the address on emergency machines.

Third time: The automation backup box had a typo in its tag, all nightly backups failed, and in the morning the monitoring board revealed "the disk isn't dead, it's the policy." After any policy change, spot-check three paths: one manual remote login, one backup job, one friend joining the game server.

Gradual rollout mantra: allow first, tag second, tighten third — turn one knob at a time, and keep the old policy text around for rollback. Don't swap the relay and flip the whole network to deny-all on the same day.

The control plane is production too

# 升级前:停服或至少快照数据库(路径按你的部署)
cp -a /var/lib/headscale/db.sqlite /backup/headscale-db-$(date +%F).sqlite

Losing the control plane database means losing your identity map: nodes have to be re-trusted, policy has to be redone. Delete long-unused nodes from the control plane; set expiry on invite links. Keep client versions roughly uniform to avoid the ghost problem of "half-connected." Watch the control plane process and disk — don't let logs fill the disk. If the control plane dies, the prettiest mesh is just a pile of unreachable peers.

Hand-written WireGuard or Headscale?

Condition Prefer
Few nodes, fixed topology, you want full transparency Hand-written tunnels
Many nodes, NAT traversal, per-user or per-tag policy Headscale or official coordination
Don't want to maintain a control plane at all Official hosted control plane

The mesh lets me open my laptop in a coffee shop like I'm at home — provided the control plane is alive, and every tightening left an exit. Zero trust isn't a sticker on the topology diagram; it's whether your hand shakes when you edit policy, and whether there's a second way home when it does.

If you're migrating from hand-written tunnels: run both in parallel for a while. Get node registration and the tag system humming first, then migrate access paths, and only then tear down the old tunnels. The "dual path" during migration is itself the best out-of-band insurance — much harder to lock yourself out than writing the perfect policy and switching all at once.

← Back to Blog