Headscale: Self-Hosting Tailscale's Control Plane

Keeping the mesh, dropping the dependency on someone else's coordination server

Contents

Tailscale’s pitch has always been that the hard part of a mesh VPN — key exchange, NAT traversal, relay fallback — is handled by a coordination server you never have to think about. I’ve written before about what Tailscale actually is and why that model is such a relief after a decade of hand-rolled WireGuard configs. The catch is that “you never have to think about it” also means “someone else’s infrastructure is a required dependency of your home network,” which sits uneasily on a site that otherwise argues for self-hosting everything it reasonably can. Headscale is the answer: an open-source, largely API-compatible reimplementation of Tailscale’s control plane that you run yourself. The clients don’t change at all — you point the standard Tailscale app at your own server instead of login.tailscale.com, and everything else about the mesh behaves the same.

What the control plane actually does

Advertisement

It helps to be precise about what Headscale replaces, because it’s easy to overstate. Tailscale itself is built on WireGuard, and WireGuard’s actual data-plane encryption is peer-to-peer — the control plane never sees your traffic. What the control plane (Tailscale’s or Headscale’s) does is coordination: it authenticates devices, hands out a stable virtual IP from the 100.64.0.0/10 CGNAT range, distributes each device’s WireGuard public key to the peers it’s allowed to talk to, enforces ACLs, and helps peers find a working path to each other — direct if possible, relayed through a DERP server if NAT makes direct connection impossible.

None of that requires trusting a third party with your data. It does require trusting them with metadata: which of your devices exist, which ones talk to which. For a lot of people that’s a fine trade for the convenience. For a homelab that already self-hosts DNS, backups, and half a dozen other things, running the coordination server too is a natural extension — and it removes the (small, but real) risk of losing mesh connectivity if a hosted service has an outage or changes its pricing terms under you.

It’s worth being fair to Tailscale here, because the honest case for Headscale isn’t that the hosted product does anything wrong. Tailscale’s free tier is genuinely generous, the client is polished, and the coordination server they run has an uptime record most homelabbers couldn’t match with their own infrastructure on their best day. The reason to run Headscale comes down to the same instinct that drives every other self-hosting decision on this site: a home network’s core connectivity shouldn’t have a hard dependency on a company’s business decisions five years from now, however reasonable those decisions look today. A pricing tier change, an acquisition, a shift in what counts as an allowed use case — none of these are likely, but “unlikely” and “acceptable to depend on for the thing that lets your laptop reach your NAS” are different bars.

Choosing SQLite or PostgreSQL up front

Headscale’s database choice is one of the few decisions worth getting right at install time rather than deferring, because migrating between backends later means exporting and reimporting rather than a config flip. SQLite is the right starting point for anything under a few dozen nodes — it’s genuinely zero-maintenance, backs up as a single file copy, and Headscale’s own load on it is light enough that performance is a non-issue at homelab scale. PostgreSQL earns its keep once the node count grows into the hundreds (multi-user households running a lot of IoT tags, or a mesh that’s grown to cover extended family), or once you want point-in-time recovery and replication semantics a single SQLite file doesn’t offer. Starting on SQLite and migrating later is entirely supported, so there’s no need to over-provision for a homelab of five devices on day one.

Standing up a Headscale server

Advertisement

Headscale ships as a single Go binary with no runtime dependencies beyond a SQLite or PostgreSQL database, which makes it one of the more pleasant self-hosted services to deploy. A minimal docker-compose.yml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
services:
  headscale:
    image: headscale/headscale:0.22.3
    container_name: headscale
    restart: unless-stopped
    command: serve
    volumes:
      - ./config:/etc/headscale
      - ./data:/var/lib/headscale
    ports:
      - "8080:8080"   # gRPC/HTTP control API
      - "9090:9090"   # metrics
    networks:
      - default

The config file needs a server URL that clients can reach — this has to be a stable, publicly resolvable name if you want devices to register from outside your LAN, which is the whole point of a mesh VPN:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# config/config.yaml
server_url: https://headscale.example.com
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090

database:
  type: sqlite
  sqlite:
    path: /var/lib/headscale/db.sqlite

dns:
  magic_dns: true
  base_domain: mesh.example.com
  nameservers:
    global:
      - 1.1.1.1
      - 9.9.9.9

Put it behind a reverse proxy for TLS — see the Caddy write-up if you haven’t already got one — and the server side is essentially done. headscale.example.com here is illustrative; point it at whatever domain and reverse proxy you actually run.

Enrolling clients and managing users

Headscale organises devices under “users” (namespaces, in older terminology), and the enrolment flow deliberately mirrors Tailscale’s own:

1
2
3
4
5
# on the server
docker exec headscale headscale users create homelab

# on the client machine
tailscale up --login-server https://headscale.example.com

The client prints a URL to visit, but since there’s no hosted OAuth flow behind a self-run server, Headscale expects you to approve it server-side instead:

1
2
3
docker exec headscale headscale nodes register \
  --user homelab \
  --key nodekey:1a2b3c4d5e6f...

For unattended machines — a server, a Raspberry Pi, anything that can’t sit through an interactive login — pre-authenticated keys skip that step entirely:

1
2
docker exec headscale headscale preauthkeys create --user homelab --reusable --expiration 24h
tailscale up --login-server https://headscale.example.com --authkey <printed-key>

ACLs

Headscale’s access control lists use the same JSON/HuJSON syntax as Tailscale’s hosted ACLs, defined server-side in a policy file rather than a web console. A reasonably restrictive home policy, separating a “trusted” group from an “iot” group of devices that should only reach one internal service:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "groups": {
    "group:trusted": ["alice@", "phone-alice"],
    "group:iot": ["camera-relay"]
  },
  "acls": [
    { "action": "accept", "src": ["group:trusted"], "dst": ["*:*"] },
    { "action": "accept", "src": ["group:iot"], "dst": ["100.64.0.5:8123"] }
  ]
}

The default policy, if you don’t supply one, allows all nodes to reach all other nodes — fine for a single-person mesh, worth tightening the moment more than one trust level exists on it. Reload it without restarting the whole server: headscale policy set --file /etc/headscale/acl.json — although in some releases this is still a config-file-plus-restart affair, so check the version’s docs before assuming a live reload exists.

Writing the first real ACL policy is where most people discover that “deny by default” and “the default policy allows everything” are two different starting points, and Headscale ships with the second one. That’s a sensible default for a brand-new install where you’re still confirming clients can even register, but it’s worth deliberately flipping to an explicit-allow model the moment the mesh has more than one person’s devices on it. The pattern that’s worked well for me: start with the permissive default while onboarding devices, confirm everything that should connect actually does, then write an explicit policy that only grants what you’ve just observed being used, and watch nft-style logs or Headscale’s own connection metrics for anything that stops working — that’s your signal you missed a legitimate flow, not that the restrictive policy is wrong.

DERP relays and NAT traversal

Advertisement

Direct peer-to-peer WireGuard connections work when both sides can establish a path — usually straightforward on a home LAN, harder across two separate CGNAT’d connections (see the CGNAT piece for why that keeps coming up). When a direct path isn’t possible, traffic falls back to relaying through a DERP server. Tailscale operates a global fleet of these for hosted accounts; Headscale expects you to either point at Tailscale’s public DERP map or run your own.

Running your own DERP relay matters if you care about relayed traffic never leaving infrastructure you control, or if the public DERP map is inconveniently far from your nodes’ actual locations, adding latency to every relayed packet. A small dedicated DERP server on a cheap VPS with a public IP works well precisely because it solves the CGNAT problem for the rest of the mesh — a topic that pairs directly with running your own relay.

Subnet routers and exit nodes

Most of what makes Headscale useful day to day comes down to advertising routes, so devices that never run the Tailscale client can still be reached through one that does. A single low-power box on the LAN — the same OPNsense-adjacent box or a small Raspberry Pi already doing other chores — can advertise the whole home subnet:

1
2
3
sudo tailscale up --login-server https://headscale.example.com \
  --advertise-routes=192.168.1.0/24 \
  --accept-routes

The route doesn’t go live automatically — Headscale requires explicit approval server-side, which is a deliberate safety check against a misconfigured or compromised node quietly announcing routes it has no business advertising:

1
2
docker exec headscale headscale nodes list-routes
docker exec headscale headscale nodes approve-routes --identifier 3 --routes 192.168.1.0/24

Once approved, every other device on the mesh can reach anything on 192.168.1.0/24 through that one subnet router, without installing Tailscale on the printer, the NAS, or anything else that has no business running a VPN client. The same mechanism handles an exit node — a device that advertises 0.0.0.0/0 and ::/0 instead of a specific subnet, routing all of a client’s internet traffic through the homelab. Useful on untrusted Wi-Fi; also a genuinely handy way to make a laptop’s outbound traffic look like it’s coming from home when a self-hosted service or a router-level allow-list only trusts the home IP.

1
sudo tailscale up --login-server https://headscale.example.com --advertise-exit-node
1
docker exec headscale headscale nodes approve-routes --identifier 3 --routes "0.0.0.0/0,::/0"

Client-side, picking the exit node is a one-line toggle: tailscale up --exit-node=<node-name>.

Tags for devices without a human owner

Beyond the group: construct shown earlier, Headscale supports device tags for ACL targeting that doesn’t depend on which human user enrolled a node — useful because “which user owns this device” is a fuzzy question for a NAS or a subnet router that nobody personally logs into.

1
docker exec headscale headscale nodes tag --identifier 3 --tags tag:server
1
{ "action": "accept", "src": ["group:trusted"], "dst": ["tag:server:*"] }

Tags decouple the policy from the enrolment history of a device, which matters once the mesh has been running long enough that half the nodes were registered by someone who’s since re-provisioned their laptop three times.

Tags earn their keep even harder once automation gets involved. A preauthkey used by a provisioning script to enrol a fleet of Raspberry Pis can be scoped with a default tag at creation time (--tags tag:server on the preauthkeys create command), so every node it stamps out arrives already correctly classified rather than needing a human to run nodes tag on each one afterwards. For anyone running more than a handful of headless devices, that one flag is the difference between ACL policy staying accurate over time and slowly drifting as devices get re-provisioned and nobody remembers to re-tag them.

Troubleshooting

Clients register but show as offline in headscale nodes list. Almost always a firewall blocking UDP for WireGuard’s STUN-like NAT traversal, forcing everything through DERP, which then also fails if outbound HTTPS to the DERP host is blocked too. Check tailscale ping <peer> on a client — if it reports “via DERP” persistently instead of a direct connection, look at the NAT and firewall rules on both ends before assuming Headscale itself is broken.

MagicDNS resolves nothing. MagicDNS depends on base_domain in the server config matching what clients expect, and on the client actually accepting DNS settings from the server — check tailscale status --json for MagicDNSSuffix matching your configured domain, and confirm the client wasn’t started with --accept-dns=false.

A pre-auth key works once and then every subsequent registration fails. Reusable keys need --reusable explicitly at creation time — a plain preauthkey is single-use by default, which is a sensible default that nonetheless catches almost everyone the first time they try to provision a batch of IoT nodes from one key.

Headscale’s SQLite database grows and queries slow down over months. This is a known long-running-instance issue in some versions from expired nodes and old routes never being pruned. headscale nodes list --deleted shows what’s accumulated; migrating to PostgreSQL is worth doing proactively once you’re relying on the mesh daily rather than waiting for it to become a problem.

A node keeps re-registering as a “new” device after every reboot. The client’s state directory (/var/lib/tailscale by default) needs to persist across reboots — common on containers or ephemeral VMs where that path isn’t on a mounted volume, so every restart looks like a brand-new machine asking for approval.

Is it worth it

If you have one or two devices and want a private network with the least possible fuss, the hosted Tailscale service is genuinely good and the free tier covers most homelabs without friction — running Headscale to save that cost isn’t the point. The point is control: no third party sees which of your machines exist or talk to each other, no dependency on someone else’s uptime for your own LAN-to-LAN connectivity, and no limit on device count imposed by a pricing tier. It also compares usefully against NetBird if you’re evaluating self-hosted mesh options generally rather than committing to the Tailscale-compatible ecosystem specifically. For a homelab that already self-hosts its DNS and reverse proxy, adding Headscale is a Saturday afternoon, and the client experience — the actual thing people love about Tailscale — doesn’t change one bit.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.