Tailscale's free tier covers most of what I need, but on web01 I run my own control plane because I don't want my homelab's coordination layer sitting behind someone else's SSO outage. I spent a weekend about a year ago wiring this up in a Proxmox VM, and now it's the thing I SSH through every day. Writing it down before I forget the ten-minute version.

To be clear up front: if you have three friends and a Minecraft server, do not run Headscale. Tailscale's free tier is excellent and you will not hit the 100-node limit. I wrote about that exact use case over in Tailscale for game servers, friends only. This post is for the other crowd — the people who already have a domain, a reverse proxy, and a slightly unhealthy interest in running their own infra.

TL;DR

# docker-compose.yml
services:
  headscale:
    image: headscale/headscale:0.23
    container_name: headscale
    restart: unless-stopped
    command: serve
    ports:
      - "127.0.0.1:8080:8080"
      - "127.0.0.1:9090:9090"
    volumes:
      - ./config:/etc/headscale
      - ./data:/var/lib/headscale

Then on a client:

sudo tailscale up --login-server=https://headscale.mydomain.tld --authkey=tskey-...

That's the whole thing. The rest is reverse proxy, DNS, and learning which knobs Headscale doesn't have.

Why Headscale (and why most of you should close this tab)

Headscale is a Go binary, MIT-licensed, originally written by Juan Font as an open reimplementation of Tailscale's control server. Tailscale Inc. is friendly toward it — some of their engineers contribute — but it is not an official product and there is no support contract. You are the support contract.

Honest reasons to run it:

  • No node-count cap. I'm at 31 nodes across two sites and a couple of throwaway VPS boxes. The free tier ceiling is 100 these days, which is generous, but I like not thinking about it.
  • Sovereignty. My coordination metadata (who's online, what IPs, which keys) lives on my hardware. The actual WireGuard tunnels are peer-to-peer either way, but the key exchange goes through whoever runs the control plane.
  • No vendor lock-in. If Tailscale Inc. ever changes pricing or gets acquired by somebody I don't trust, I want my exit already wired up.
  • Learning. I understood WireGuard better after running my own control plane than I did after a year of using Tailscale's. If you've already done the manual WireGuard setup, Headscale is the logical next step.

Honest reasons NOT to run it:

  • You want Funnel (public ingress for tailnet services). Headscale doesn't do this. It's a Tailscale-cloud-only feature.
  • You want the slick admin web UI for SSH session recording. Doesn't exist here. There's a community web UI but it's bolted on.
  • You don't already have a publicly reachable server with a domain and TLS. You'll need one.
  • You want it to "just work" for your non-technical family. The free tier wins on UX every time.

If you're still here, fine. Let's set it up.

Install via Docker compose on Ubuntu

I run this on a small Ubuntu 24.04 VM. 1 vCPU, 1 GB RAM is genuinely plenty — Headscale is sippy.

mkdir -p ~/headscale/{config,data}
cd ~/headscale
curl -L -o config/config.yaml \
  https://github.com/juanfont/headscale/raw/main/config-example.yaml

Edit config/config.yaml. The bits that actually matter:

server_url: https://headscale.mydomain.tld
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090

prefixes:
  v4: 100.64.0.0/10
  v6: fd7a:115c:a1e0::/48

noise:
  private_key_path: /var/lib/headscale/noise_private.key

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

dns:
  magic_dns: true
  base_domain: ts.mydomain.tld
  nameservers:
    global:
      - 1.1.1.1
      - 9.9.9.9

SQLite is fine for under a few hundred nodes. Don't bother with Postgres unless you actually need it — that's a maintenance tax for nothing.

docker-compose.yml:

services:
  headscale:
    image: headscale/headscale:0.23
    container_name: headscale
    restart: unless-stopped
    command: serve
    ports:
      - "127.0.0.1:8080:8080"
      - "127.0.0.1:9090:9090"
    volumes:
      - ./config:/etc/headscale
      - ./data:/var/lib/headscale

Note the image is headscale/headscale now — the older juanfont/headscale tag still exists and points at the same project, but the official one moved. Both work.

docker compose up -d
docker compose logs -f

Lock the host down with UFW — only 80/443 from the public internet, port 8080 stays bound to localhost so only the reverse proxy reaches it.

First user and a preauth key

Headscale calls them "users" now (they used to be "namespaces" — old docs lie). One user maps to one human or one logical group of devices.

docker exec headscale headscale users create lukas
docker exec headscale headscale preauthkeys create \
  --user lukas --reusable --expiration 24h

That spits out a tskey-auth-... string. Copy it. You can also do --ephemeral for short-lived CI runners — those nodes get garbage-collected automatically when they go offline.

To check what you've got:

docker exec headscale headscale users list
docker exec headscale headscale nodes list

I keep a tiny shell alias hs='docker exec headscale headscale' because typing the full thing thirty times a day is for the birds.

Connect a client

The official Tailscale client. Same one. You just point it at your server:

sudo tailscale up \
  --login-server=https://headscale.mydomain.tld \
  --authkey=tskey-auth-...

For interactive (browser) login, drop --authkey and Headscale will print a URL in the logs like https://headscale.mydomain.tld/register/nodekey:abc.... Visit it, paste the corresponding headscale nodes register command on the server side, done. The authkey flow is much faster for fleet stuff.

On Windows and macOS the Tailscale client also takes --login-server as a flag, but the GUI hides it. On Windows you set the registry key LoginURL under HKLM\SOFTWARE\Tailscale IPN. On macOS the App Store build refuses custom servers — you need the standalone variant from the Tailscale website.

MagicDNS and DNS

MagicDNS is the killer feature and it works fine on Headscale. With magic_dns: true and a base_domain set, every node becomes resolvable as <hostname>.<user>.ts.mydomain.tld. So web01.lukas.ts.mydomain.tld resolves to its 100.x.y.z address from any other node on the tailnet.

Don't use a domain you also use for public DNS. I use ts.mydomain.tld as a dedicated subzone — it has no public records, exists only inside the tailnet, and that prevents weird split-horizon surprises.

If you want ACLs (and you do), they live in a HuJSON file referenced by acl_policy.path in config.yaml. HuJSON is JSON with comments and trailing commas, which is honestly nicer than the alternative. Reload with headscale policy set after edits — no restart needed.

Reverse proxy

Headscale needs HTTPS on a real domain. WebSockets are required for the long-poll node updates, so make sure your proxy isn't terminating them.

I use nginx because I already had it. The full config and certbot story is in my nginx reverse proxy post — for Headscale you just need proxy_http_version 1.1, the Upgrade/Connection headers, and a long proxy_read_timeout (at least a few minutes, otherwise nodes constantly reconnect).

If you're starting fresh, honestly, just use Caddy. Auto-HTTPS, two-line config, works the first try. I wrote up that comparison in Caddy vs nginx for auto-HTTPS. Caddy config for Headscale is literally:

headscale.mydomain.tld {
    reverse_proxy 127.0.0.1:8080
}

That's it. That's the whole reverse proxy.

Gotchas

Client version mismatches. Tailscale clients move fast. Headscale lags by a release or two and occasionally a new client feature won't land. Stick to the supported client matrix in the Headscale release notes — I had a week where 1.66 clients couldn't register against an older Headscale and the error was useless. Pin client versions on critical nodes.

OIDC integration is a rabbit hole. Headscale supports OIDC (Authelia, Keycloak, Authentik) and on paper it's clean — in practice you will spend a Saturday on group claim mappings and oidc.allowed_groups. If you only have a handful of users, just use preauth keys and don't open this door.

DERP servers default to Tailscale's. When direct peer-to-peer fails (CGNAT, hostile firewalls), traffic falls back through DERP relays. By default Headscale uses Tailscale Inc.'s public DERP map. That's free and works, but it does mean your fallback traffic goes through their infra. You can run your own DERP — it's the same tailscale binary in a different mode — and point Headscale at it via derp.urls / derp.paths. Worth it for the truly paranoid; overkill for most.

Exit-node selection is fine but plain. headscale nodes list shows which nodes are advertising as exits, you approve them with headscale nodes route enable, and clients pick them with tailscale up --exit-node=.... There's no UI for it, no per-user exit policies in ACLs the way Tailscale has. If you swap exits often you'll write a wrapper script. I did.

Should you run this?

If you're running a serious homelab, have a domain, and already feel comfortable with Docker, reverse proxies, and TLS — yes. It's a weekend to stand up, hours per year to maintain, and you'll learn the protocol properly. The peace of mind from owning your coordination plane is real.

If you're using Tailscale for SSH'ing into a couple of boxes and sharing a Plex with friends — no. The free tier is genuinely free, the UX is better, Funnel is excellent, and you have better things to do with a Saturday. There's no shame in it. I still use Tailscale Inc.'s control plane on my laptop and phone for personal stuff; Headscale runs the actual server-to-server fabric.

Self-host the things where ownership matters. Use the SaaS where it doesn't. That's the whole rule.


Related posts