Headscale
Self-hosted Tailscale coordination server. Keeps device metadata on your own infrastructure. Upstream docs.
- URL:
hs.{domain}(must be public — clients contact it before joining the tailnet) - STUN: 3478/udp (embedded DERP relay, region 999)
- Data:
/var/lib/headscale(SQLite DB + noise keys)
Deploy
Section titled “Deploy”auberge ansible run --tags headscaleRequired config
Section titled “Required config”| Key | Purpose |
|---|---|
headscale_subdomain |
Subdomain for the control plane (e.g. hs) — the deploy gate: unset or blanked per host, the role and its STUN firewall rule are skipped |
tailscale_login_server |
Headscale URL (e.g. https://hs.example.com) — passed to tailscale up --login-server. When set, nodes use Headscale instead of Tailscale SaaS. |
Enrollment keys
Section titled “Enrollment keys”A pre-auth key is one-shot and expires. The CLI mints one per run rather than storing it (ADR-0063): a run entering the tailscale role asks the target tailscale status --json — the role’s own check — and if its tailscaled needs a login, mints a 1-hour key stamped with the target’s tailnet_tag and injects it as tailscale_authkey. An already-enrolled target costs one probe and nothing else.
tailscale_authkey is therefore not required config. It stays a settable key for one case: bootstrapping the host that will serve headscale, which has no coordinator to mint against. Set it there, or leave it unset everywhere else.
| The run finds… | What happens |
|---|---|
the target’s tailscaled authenticated |
nothing minted; the role skips tailscale up |
| one host serving headscale | a 1h key minted for the tailnet’s single user, tagged with the target’s tier |
| no host serving headscale | nothing minted; tailscale_authkey from config.toml stands (the first-host case) |
--check |
nothing minted — a dry run must not write a credential row |
| several such hosts, or several users | the run stops — there is no operator mid-deploy to ask which |
First-run sequence
Section titled “First-run sequence”auberge ansible run --tags headscaleauberge headscale add-user --host my-vps # the tailnet's one userauberge config set tailscale_login_server https://hs.example.comauberge ansible run --tags tailscale # keys minted per run from here onPeers that cannot reach each other directly relay through a DERP node. The embedded server on this Host is region 999; Tailscale’s public DERP map is merged in beside it so relayed traffic survives this Host going down. Clients pick their home region by measured latency (tailscale netcheck), so the self-hosted one wins where it is closest rather than by configuration. Relay traffic is end-to-end encrypted WireGuard; a DERP node forwards it without being able to read it.
| Role var | Default | Purpose |
|---|---|---|
headscale_derp_urls |
["https://controlplane.tailscale.com/derpmap/default"] |
Remote DERP maps merged with the embedded region |
headscale_derp_update_frequency |
24h |
How often to re-fetch them |
Tailnet DNS
Section titled “Tailnet DNS”Headscale pushes the host’s Blocky as the tailnet’s global nameserver, so every query from every enrolled device is filtered — not just *.{domain}. The address is discovered at deploy time from the host’s own tailscale status; before the host is enrolled there is nothing to discover and the config falls back to 1.1.1.1/1.0.0.1.
A deploy that finds no tailnet IPv4 on a Host whose config already serves one fails rather than rendering the public fallback over it — an unreachable tailscaled must not silently unfilter every enrolled client.
Verify from any node:
tailscale dns status # Resolver should be the host's tailnet IPheadscale_split_dns_target_ip stays available for pointing one domain at a different resolver — set it in inventory or via extra-vars; it is not a config.toml key. It is not how filtering reaches a client.
Tailnet ACL policy
Section titled “Tailnet ACL policy”The tailnet runs a tag-based, default-deny policy (ADR-0055). The role deploys policy.hujson beside config.yaml and points headscale at it with policy.mode: file — repo-owned, not a headscale policy set into the DB. Its presence flips the tailnet from allow-all to deny-by-default.
Trust is a tag; which node carries which is set by auberge headscale add-key -t tag:... at enrollment or by auberge headscale tag-node afterwards, so the policy names tiers, never nodes.
A host in the roster declares its tier as the tailnet_tag field of its hosts.toml entry — one of the four below, refused at parse time if it is anything else (ADR-0062). Parsing checks the value against the CLI’s own closed type; what ties that type to this file is a test holding the two sets equal, so the four stay one vocabulary. That is the declaration; the commands above are what applies it. Nodes outside the roster (lechuck, pixel-9a) have no entry and are tagged by command alone.
| Tag | Example nodes | May initiate to |
|---|---|---|
tag:trusted |
lechuck, pixel-9a | everything |
tag:data |
auberge | trusted/data/standby; never tag:agent |
tag:agent |
ruche | the global resolver on 53 only |
tag:standby |
vieille-auberge | trusted/data/standby; never tag:agent |
Every tag reaches the host’s Blocky global resolver on 53 (ADR-0052) — the one tailnet path open to tag:agent. tagOwners lists are empty: only the admin CLI that mints the keys may apply these tags, so the policy carries no operator-specific username.
Rolling out a first policy
Section titled “Rolling out a first policy”A tailnet’s first policy is deployed twice (ADR-0061). Neither obvious order works: tag-node cannot run before a policy is loaded (see the warning above), and deploying the real policy onto an untagged tailnet matches nobody — including the DNS carve-out, whose dst is tag:data:53 — so it partitions rather than confines.
- Deploy a bridge: the full
tagOwnersset with the ACL left at{"src": ["*"], "dst": ["*:*"]}. Reachability is unchanged;tag-nodestarts working. - Tag every enrolled node into its tier.
- Deploy the real policy.
Verify with a before/after probe run at each step. Step 1 must show no change at all — if it does, the bridge was not inert.
The policy’s own tests
Section titled “The policy’s own tests”policy.hujson carries a tests block asserting what its rules are for. headscale policy check -f <file> evaluates it against the live fleet without applying anything — run it between steps 2 and 3:
scp policy.hujson auberge:/tmp/candidate.hujsonssh auberge 'sudo headscale policy check -f /tmp/candidate.hujson'The same tests re-run on every policy load, so a rule change that reopens a confined path stops the policy rather than shipping quietly.
Put a node in a tier:
auberge headscale add-key -t tag:agent # at enrollment: mint a key stamped tag:agentauberge headscale tag-node lechuck -t tag:trusted # afterwards: set an enrolled node's tagsA node never asserts its own tag. tailscale up --advertise-tags is a node-side claim headscale validates against tagOwners, where a key-stamped tag is server-forced and applied unchecked — two writers for one fact, and a rejected claim lands as a silently invalid tag on the node record. The tailscale role’s tailscale_advertise_tags was deleted for that reason and tests/headscale_acl_policy.rs keeps it deleted.
Migration from Tailscale SaaS
Section titled “Migration from Tailscale SaaS”On each node: tailscale logout, then re-run auberge ansible run --tags tailscale. Verify with tailscale status.
auberge ansible run --tags infrastructureBackup
Section titled “Backup”auberge backup create --apps headscale snapshots /var/lib/headscale (SQLite + keys). No external DB.