How the tailnet works
The tailnet is the network’s mesh VPN: it connects zones, the coordination server (HCS) and remote devices. Headscale 🡕 coordinates it, Tailscale 🡕 brings it to life on each node. This page explains who is on it, how packets and DNS queries travel, who can reach what, and how a machine joins. Operational procedures are in VPN.
Control plane, data plane
Section titled “Control plane, data plane”Headscale distributes information, never traffic: nodes exchange directly with each other, encrypted with WireGuard 🡕.
| Plane | Carried by | Content |
|---|---|---|
| Control | Headscale on the HCS (HTTPS) | identities, public keys, addresses, routes, DNS, ACL filters |
| Data | each Tailscale client (UDP 41641) | the traffic, from one node to another |
| Relay | Tailscale public DERP servers | the encrypted traffic, when no direct path exists |
- Nodes punch through NAT to connect directly. UDP port 41641 is open on the HCS and on the gateways’ WAN, which helps behind strict NAT.
- Without a direct path (filtered UDP, strict NAT on both sides), traffic goes through a DERP relay: always end-to-end encrypted, but slower.
- HCS unavailable: established tunnels keep working, but nothing changes (new node, new route, new policy) until it is back.
Who is on the tailnet
Section titled “Who is on the tailnet”Two kinds of identities coexist: machines, identified by a tag that tells their role, and personal devices, attached to their owner.
| Node | Identity | Obtained by | Expiration |
|---|---|---|---|
| HCS | tag:hcs | one-time key (just tailnet-enroll) | never |
| Zone gateway | tag:gw-<zone> | one-time key | never |
| Admin workstation | tag:admin | one-time key | never |
| Personal device | its owner (alice@) | Kanidm sign-in | 180 days |
- Tags derive from the topology:
hcsprofile, zone gateway, host whosegroupscontainsadminand which enables the Tailscale client. - A tagged node belongs to no one (
tagged-devicesin Headscale). - Only accounts in the Kanidm
tailnetgroup connect a device: admins and zone members (groupzone-<zone>). - A host’s free
tagsinetc/config.yamlgrant no rights. - Each node carries a MagicDNS name:
<node>.tailnet.internal.
Routing
Section titled “Routing”The tailnet gives every node an address and links the zone subnets.
Path of a workstation in the maison zone to a server in the atelier zone:
| Path | Route | Mechanism |
|---|---|---|
| Workstation of one zone → another zone | its gateway, the tailnet, the remote gateway | subnet routes |
| Remote device → a zone’s service | the tailnet, the zone’s gateway | the zone’s subnet route |
| Remote device → global service | the tailnet, the HCS | HCS tailnet address |
| Node → node | direct | 100.64.x.y addresses |
| Node → Internet | its own connection | HCS exit node closed by default |
- Addresses: Headscale assigns them in order, within
100.64.0.0/10(andfd7a:115c:a1e0::/48). A reinstalled machine (new machine key) gets a new one. - Advertised routes: each gateway advertises its zone’s
/16. The policy approves it outright fortag:gw-<zone>only: a gateway cannot divert another zone’s subnet. - Accepted routes: each client installs the other zones’
/16in Tailscale’s routing table 52, consulted before the main table. A gateway does not install its own zone there. - Without SNAT: a gateway relays without masking the source
(
--snat-subnet-routes=false). A machine in the zone sees the real tailnet address and answers through its default route, the gateway; hence the gateways’ permissive reverse-path filtering (ruleR12of security). - Exit node: the HCS advertises itself as an Internet exit, approved outright,
but no rule authorizes it while
policy.exitNodeSourcesis empty. - Filtering: the policy applies on arrival. For a zone, its gateway is what filters what enters from the tailnet.
What a gateway lets through is detailed in What crosses a gateway.
Every machine queries a different resolver, but a zone’s names always end up with the same owner: that zone’s gateway.
| Machine | Resolver | A zone’s names | Global services | Internet |
|---|---|---|---|---|
| Workstation of a zone, gateway | gateway’s dnsmasq, behind AdGuard Home if enabled | dnsmasq, or the zone’s gateway | dnsmasq, or the HCS’s unbound | public DNS, filtered by AdGuard Home |
| HCS | unbound, local | the zone’s gateway | unbound | Quad9, encrypted |
| Remote device | MagicDNS (100.100.100.100) | through the HCS’s unbound | through the HCS’s unbound | resolver of the visited network |
- Gateway: dnsmasq knows the machines of all zones and the declared services.
An unknown name of another zone goes to that zone’s gateway (LAN address, through
the tailnet); the rest of
domain.tldgoes to unbound, on the HCS. - A zone’s services:
*.<zone>.domain.tldpoints to the zone gateway’s LAN address, which serves them behind its proxy. - HCS: unbound listens on
127.0.0.1and on the HCS’s tailnet address, for tailnet addresses only. It forwards<zone>.domain.tldto the gateway’s resolver, at its tailnet address: hence the importance of declaringvpn.ipv4. headscale.domain.tld: never requested on the tunnel it helps set up, see Booting the control plane.
MagicDNS
Section titled “MagicDNS”MagicDNS is the small resolver each Tailscale client embeds, at the address
100.100.100.100. Headscale enables it for the whole tailnet and pushes its
settings to it:
| Setting | Value | Effect |
|---|---|---|
| Node names | tailnet.internal | gw-atelier.tailnet.internal → tailnet address, kept up to date by Headscale |
| Search domain | tailnet.internal | ssh gw-atelier is enough |
| Split DNS | domain.tld → HCS unbound | internal names go, through the tunnel, to the pivot resolver |
| Global resolver | none | the rest goes to the device’s usual resolver |
Where it comes in
Section titled “Where it comes in”A client only uses it as a resolver if it accepts the tailnet’s DNS
(--accept-dns).
| Machine | --accept-dns | MagicDNS role |
|---|---|---|
| Personal device, laptop away from its zone | yes | system resolver: node names and split DNS |
| Laptop reconnected to its zone | client paused | none, the local gateway takes over |
| Gateway | no | reverse names of 100.x addresses, for AdGuard Home statistics |
| HCS | yes | present, but the system queries unbound (127.0.0.1) |
| Zone workstation | no client | none |
- Name nodes outside zones: a laptop on the move reaches
gw-atelierdirectly over the tailnet, without relying on a subnet route or a zone’s DNS. That is whatjust enter gw-atelierdoes away from home. - Resolve internal names everywhere: split DNS is enough, without routing all DNS through the VPN.
- Keep the Internet out of the tunnel: if the VPN path weakens, public name resolution holds (why).
- Leave control to gateways: they keep their own DNS (
--accept-dns=false), which their zone and its clients depend on.
How it fits together
Section titled “How it fits together”A remote device opens a service of the maison zone:
- MagicDNS recognizes
domain.tldand forwards the query, through the tunnel, to unbound. - unbound forwards it to the
maisongateway’s resolver, at its tailnet address. - The answer is the gateway’s LAN address (
10.0.1.1). - The device reaches it through the zone’s subnet route; the policy authorizes it
on port 443 (
gateway-maison).
Access policy (ACL)
Section titled “Access policy (ACL)”On the tailnet, nothing passes by default. Headscale compiles a policy into filters it pushes to each node; each drops whatever no rule allows. For a zone, filtering happens at its gateway.
The policy is not hand-written: DNF generates it from etc/config.yaml (zones,
hosts, users), validates it at build time (headscale policy check), then
Headscale hot-reloads it at deployment.
The building blocks
Section titled “The building blocks”| Block | Generated content |
|---|---|
groups | group:admins (admin, nix-admin profiles), group:zone-<zone> (zone-<zone> group) |
tagOwners | tag:hcs, tag:gw-<zone>, tag:admin, owned by group:admins |
hosts | zone-<zone> (subnet), gateway-<zone> (gateway LAN address), admin workstations (LAN address), adminDevices, extraHosts |
autoApprovers | route of each zone → tag:gw-<zone>; exit node → tag:hcs |
acls | the rules below |
The rules
Section titled “The rules”| # | Source | Destination | Purpose |
|---|---|---|---|
| 1 | tagged machines, zone subnets | all machines and zones, all ports | infrastructure: DNS, monitoring, backups, deployment |
| 2 | personal devices (autogroup:member) | HCS, ports 53 and 443 | internal DNS, global services |
| 3 | group:admins | gateways of all zones, port 443 | all zones’ services |
| 4 | group:zone-<zone> | its zone’s gateway, port 443 | its zones’ services |
| 5 | personal devices | devices of the same owner | between one’s own devices |
| 6 | tag:admin, admin workstations, adminDevices | all machines and zones, port 22 | SSH administration |
| 7 | policy.exitNodeSources | Internet | the HCS exit node, if enabled |
| 8 | policy.extraAcls | free | the network’s own needs |
- SSH follows the workstation, not the person: an admin on their phone has
no SSH, except for a device declared in
adminDevices. gateway-<zone>in addition to the tag: a zone’s services resolve to the LAN address of its gateway, whichtag:gw-<zone>does not cover (it only designates its tailnet addresses).- An empty group disappears from the policy: a rule citing it would get the whole policy rejected.
- Open mode (
policy.enforce = false): a single rule allows everything. Reserved for a migration, while nodes are being tagged.
The options that fine-tune the policy are described in Adapting the access policy.
Pre-authorized keys and enrollment
Section titled “Pre-authorized keys and enrollment”A node proves once to Headscale that it is allowed in. After that, its node key is enough: forever if tagged, until expiry for a personal device.
| Key | Where | Role |
|---|---|---|
| Machine key | the node’s /var/lib/tailscale | identifies the machine to Headscale; lost on reinstall |
| Node key | the node’s /var/lib/tailscale | encrypts traffic; Headscale publishes its public part to peers |
| Pre-authorized key | created by Headscale | authorizes enrollment without a browser, with the tags it carries |
| OIDC session | Kanidm | authorizes a personal device’s enrollment |
Machines: a single-use key
Section titled “Machines: a single-use key”just tailnet-enroll <host> enrolls a machine, without a shared key:
- Forced tags: the HCS creates the key for the host’s declared tags
(
dnf-tailnet-enroll), whatever the caller asks for. - Single use, 10 minutes: worthless once served.
- No trace: the key goes through a pipe, never through a command line,
a variable, a disk or sudo’s input/output journal (rules
NOLOG_INPUTandNOLOG_OUTPUT). - Held in memory:
dnf-tailnet-joinputs it in/run/tailscale-enroll,tailscaled-autoconnectuses it (tailscale up --force-reauth) then erases it. - Portable in pause in its zone: it registers without routes or DNS, then pauses again.
- Final control:
adoptsets the declared tags and name, replaces an old offline node with the same name, and the recipe checks the result.
Personal devices: OIDC
Section titled “Personal devices: OIDC”- The Tailscale app contacts Headscale, which redirects it to Kanidm.
- Kanidm only accepts members of the
tailnetgroup; Headscale checks it too. - The device is enrolled under the person’s name (
alice@) and expires after 180 days (nodeExpiry): they then sign in again.