Why a self-hosted control plane

The hosted Tailscale tier is free for 100 devices and three users, which covers most home/small-team use. Headscale becomes interesting when any of these matter:

  • The coordination server should live in a known jurisdiction or on owned hardware.
  • The free tier limits (devices, users, ACL complexity) are too tight, but the paid tier is hard to justify.
  • Air-gapped or restricted environments where reaching login.tailscale.com isn't an option.
  • Curiosity about how the control plane actually works.

Headscale does not reimplement every Tailscale SaaS feature — SSH session recording, MagicDNS-routed app connectors, and some org-management UI are out of scope. The core (devices, routes, ACLs, exit nodes, key expiration) all work.

Install on Debian / Ubuntu

Headscale ships .deb packages on GitHub releases:

HS_VER=0.26.0   # check github.com/juanfont/headscale/releases for current
curl -L -o headscale.deb \
    "https://github.com/juanfont/headscale/releases/download/v${HS_VER}/headscale_${HS_VER}_linux_amd64.deb"
sudo apt install ./headscale.deb

The package installs the headscale binary, a systemd unit, a config skeleton at /etc/headscale/config.yaml, and a state directory at /var/lib/headscale.

Configuration

Edit /etc/headscale/config.yaml. The minimum changes for a public deployment:

server_url: https://hs.example.com    # public URL clients will connect to
listen_addr: 127.0.0.1:8080           # bound to localhost; reverse proxy adds TLS
metrics_listen_addr: 127.0.0.1:9090

# Local SQLite is fine for tens to hundreds of nodes; use PostgreSQL beyond that.
database:
  type: sqlite
  sqlite:
    path: /var/lib/headscale/db.sqlite

# Tailnet name — appears in MagicDNS as <hostname>.<tailnet_name>
prefixes:
  v4: 100.64.0.0/10
  v6: fd7a:115c:a1e0::/48

dns:
  base_domain: hs.example.net
  magic_dns: true
  nameservers:
    global:
      - 1.1.1.1
      - 9.9.9.9

# DERP relays (NAT-traversal fallback): use Tailscale's public DERP map by default.
derp:
  urls:
    - https://controlplane.tailscale.com/derpmap/default
  auto_update_enabled: true
  update_frequency: 24h

Enable and start:

sudo systemctl enable --now headscale
sudo systemctl status headscale
journalctl -u headscale -f

Behind a reverse proxy

Headscale itself does not terminate TLS — put it behind Caddy, nginx, or Traefik. With Caddy:

hs.example.com {
    reverse_proxy 127.0.0.1:8080

    # Headscale requires HTTP/2 for control-plane streaming.
    # Caddy enables HTTP/2 automatically — nothing to set.

    # Optional: long timeout for the long-poll endpoints
    @hs {
        path /machine/* /derp/* /ts2021
    }
    handle @hs {
        reverse_proxy 127.0.0.1:8080 {
            transport http {
                read_timeout 1h
                write_timeout 1h
            }
        }
    }
}

Once the proxy is up, browse to https://hs.example.com — the response is "Healthy" if the server is running.

Create a user and pre-auth key

Headscale's CLI talks to the local server over a Unix socket:

sudo headscale users create amir
sudo headscale preauthkeys create --user amir --reusable --expiration 24h

The preauth key is what clients use to register. --reusable lets the same key onboard multiple devices within its expiration window; for an unattended server, use --ephemeral to make the registered node auto-delete when it goes offline.

Enroll a Linux client

Install the official Tailscale client (same as for the hosted version — see the Tailscale tutorial), then point it at the Headscale server instead of Tailscale's:

sudo tailscale up \
    --login-server https://hs.example.com \
    --authkey <preauth-key>

For an interactive enrollment (no preauth key), drop --authkey; tailscale up prints a URL of the form https://hs.example.com/register/<node-key>. Approve from the Headscale server:

sudo headscale nodes register --user amir --key <node-key-from-url>

Enroll Windows / macOS

The official Tailscale GUI clients have a hidden setting for custom login servers. On Windows: registry value HKLM\Software\Tailscale IPN\LoginURL = https://hs.example.com, then restart the Tailscale service. On macOS: defaults write io.tailscale.ipn.macos LoginURL https://hs.example.com. After restarting the client, the "Sign in" button now points at Headscale.

ACLs

Headscale uses Tailscale's HuJSON ACL syntax. A minimal policy:

{
  "tagOwners": {
    "tag:web":  ["amir"],
    "tag:ci":   ["amir"]
  },

  "acls": [
    // amir can reach everything
    { "action": "accept", "src": ["amir"], "dst": ["*:*"] },

    // ci nodes can only reach web nodes on 80/443
    { "action": "accept", "src": ["tag:ci"], "dst": ["tag:web:80,443"] }
  ],

  "ssh": [
    { "action": "accept", "src": ["amir"], "dst": ["amir"], "users": ["root", "amir"] }
  ]
}

Apply:

sudo headscale policy set -f /etc/headscale/acl.hujson
sudo headscale policy check -f /etc/headscale/acl.hujson

check validates the syntax without applying. Headscale ACL parsing is strict and will refuse to load an invalid file rather than silently keep the old one.

Advertising subnets and exit nodes

Same client flags as hosted Tailscale — nothing Headscale-specific:

# On a node that should route its LAN to other tailnet members
sudo tailscale up --login-server https://hs.example.com \
    --advertise-routes 192.168.1.0/24

# Then approve the route on the Headscale server
sudo headscale routes list
sudo headscale routes enable -r <route-id>

Same idea for --advertise-exit-node on a node that should be selectable as an exit, plus headscale routes enable for the auto-generated 0.0.0.0/0 + ::/0 routes.

Backups

Two things matter:

  • /var/lib/headscale/db.sqlite — users, nodes, keys, routes, ACL state. Snapshot it nightly. With Postgres, it's a pg_dump of the headscale database.
  • /var/lib/headscale/private.key + noise_private.key — the server's identity. Lose these and every client has to re-enroll.

Both fit easily inside a restic backup of /var/lib/headscale. The encryption key on that restic repo is then the only really-secret thing in the deployment.