Skip to content

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.

Headscale distributes information, never traffic: nodes exchange directly with each other, encrypted with WireGuard 🡕.

Diagram
PlaneCarried byContent
ControlHeadscale on the HCS (HTTPS)identities, public keys, addresses, routes, DNS, ACL filters
Dataeach Tailscale client (UDP 41641)the traffic, from one node to another
RelayTailscale public DERP serversthe 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.

Two kinds of identities coexist: machines, identified by a tag that tells their role, and personal devices, attached to their owner.

NodeIdentityObtained byExpiration
HCStag:hcsone-time key (just tailnet-enroll)never
Zone gatewaytag:gw-<zone>one-time keynever
Admin workstationtag:adminone-time keynever
Personal deviceits owner (alice@)Kanidm sign-in180 days
  • Tags derive from the topology: hcs profile, zone gateway, host whose groups contains admin and which enables the Tailscale client.
  • A tagged node belongs to no one (tagged-devices in Headscale).
  • Only accounts in the Kanidm tailnet group connect a device: admins and zone members (group zone-<zone>).
  • A host’s free tags in etc/config.yaml grant no rights.
  • Each node carries a MagicDNS name: <node>.tailnet.internal.

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:

Diagram
PathRouteMechanism
Workstation of one zone → another zoneits gateway, the tailnet, the remote gatewaysubnet routes
Remote device → a zone’s servicethe tailnet, the zone’s gatewaythe zone’s subnet route
Remote device → global servicethe tailnet, the HCSHCS tailnet address
Node → nodedirect100.64.x.y addresses
Node → Internetits own connectionHCS exit node closed by default
  • Addresses: Headscale assigns them in order, within 100.64.0.0/10 (and fd7a: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 for tag:gw-<zone> only: a gateway cannot divert another zone’s subnet.
  • Accepted routes: each client installs the other zones’ /16 in 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 (rule R12 of security).
  • Exit node: the HCS advertises itself as an Internet exit, approved outright, but no rule authorizes it while policy.exitNodeSources is 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.

MachineResolverA zone’s namesGlobal servicesInternet
Workstation of a zone, gatewaygateway’s dnsmasq, behind AdGuard Home if enableddnsmasq, or the zone’s gatewaydnsmasq, or the HCS’s unboundpublic DNS, filtered by AdGuard Home
HCSunbound, localthe zone’s gatewayunboundQuad9, encrypted
Remote deviceMagicDNS (100.100.100.100)through the HCS’s unboundthrough the HCS’s unboundresolver of the visited network
Diagram
  • 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.tld goes to unbound, on the HCS.
  • A zone’s services: *.<zone>.domain.tld points to the zone gateway’s LAN address, which serves them behind its proxy.
  • HCS: unbound listens on 127.0.0.1 and on the HCS’s tailnet address, for tailnet addresses only. It forwards <zone>.domain.tld to the gateway’s resolver, at its tailnet address: hence the importance of declaring vpn.ipv4.
  • headscale.domain.tld: never requested on the tunnel it helps set up, see Booting the control plane.

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:

SettingValueEffect
Node namestailnet.internalgw-atelier.tailnet.internal → tailnet address, kept up to date by Headscale
Search domaintailnet.internalssh gw-atelier is enough
Split DNSdomain.tld → HCS unboundinternal names go, through the tunnel, to the pivot resolver
Global resolvernonethe rest goes to the device’s usual resolver

A client only uses it as a resolver if it accepts the tailnet’s DNS (--accept-dns).

Machine--accept-dnsMagicDNS role
Personal device, laptop away from its zoneyessystem resolver: node names and split DNS
Laptop reconnected to its zoneclient pausednone, the local gateway takes over
Gatewaynoreverse names of 100.x addresses, for AdGuard Home statistics
HCSyespresent, but the system queries unbound (127.0.0.1)
Zone workstationno clientnone
  • Name nodes outside zones: a laptop on the move reaches gw-atelier directly over the tailnet, without relying on a subnet route or a zone’s DNS. That is what just enter gw-atelier does 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.

A remote device opens a service of the maison zone:

Diagram
  1. MagicDNS recognizes domain.tld and forwards the query, through the tunnel, to unbound.
  2. unbound forwards it to the maison gateway’s resolver, at its tailnet address.
  3. The answer is the gateway’s LAN address (10.0.1.1).
  4. The device reaches it through the zone’s subnet route; the policy authorizes it on port 443 (gateway-maison).

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.

BlockGenerated content
groupsgroup:admins (admin, nix-admin profiles), group:zone-<zone> (zone-<zone> group)
tagOwnerstag:hcs, tag:gw-<zone>, tag:admin, owned by group:admins
hostszone-<zone> (subnet), gateway-<zone> (gateway LAN address), admin workstations (LAN address), adminDevices, extraHosts
autoApproversroute of each zone → tag:gw-<zone>; exit node → tag:hcs
aclsthe rules below
#SourceDestinationPurpose
1tagged machines, zone subnetsall machines and zones, all portsinfrastructure: DNS, monitoring, backups, deployment
2personal devices (autogroup:member)HCS, ports 53 and 443internal DNS, global services
3group:adminsgateways of all zones, port 443all zones’ services
4group:zone-<zone>its zone’s gateway, port 443its zones’ services
5personal devicesdevices of the same ownerbetween one’s own devices
6tag:admin, admin workstations, adminDevicesall machines and zones, port 22SSH administration
7policy.exitNodeSourcesInternetthe HCS exit node, if enabled
8policy.extraAclsfreethe 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, which tag: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.

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.

KeyWhereRole
Machine keythe node’s /var/lib/tailscaleidentifies the machine to Headscale; lost on reinstall
Node keythe node’s /var/lib/tailscaleencrypts traffic; Headscale publishes its public part to peers
Pre-authorized keycreated by Headscaleauthorizes enrollment without a browser, with the tags it carries
OIDC sessionKanidmauthorizes a personal device’s enrollment

just tailnet-enroll <host> enrolls a machine, without a shared key:

Diagram
  • 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_INPUT and NOLOG_OUTPUT).
  • Held in memory: dnf-tailnet-join puts it in /run/tailscale-enroll, tailscaled-autoconnect uses 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: adopt sets the declared tags and name, replaces an old offline node with the same name, and the recipe checks the result.
  1. The Tailscale app contacts Headscale, which redirects it to Kanidm.
  2. Kanidm only accepts members of the tailnet group; Headscale checks it too.
  3. The device is enrolled under the person’s name (alice@) and expires after 180 days (nodeExpiry): they then sign in again.