Skip to content

VPN (Headscale / Tailscale)

The VPN connects all zones and remote machines: Headscale 🡕 is the coordinator (on the HCS), Tailscale 🡕 is the client on each node. Overview in Network.

Enabling coordination and declaring the HCS (profile: hcs) is sufficient:

etc/config.yaml
network:
coordination:
enable: true
hostname: "hcs"
domain: "headscale"

Roles are derived from each host’s profile:

  • HCS: mesh coordination (Headscale).
  • Gateway: subnet router + exit node: publishes its zone’s subnet.
  • Other nodes: Tailscale clients.

All machines join the mesh via a shared Headscale attach user (nix). It is created once for the entire network; if it already exists, skip this step.

Fenêtre de terminal
just enter hcs
sudo headscale users list # already present? → nothing to do
sudo headscale users create nix --display-name "Nix Admin" --email "nix@domain.tld"

The gateway profile already makes the gateway a Tailscale client: at deployment, the tailscaled-autoconnect service registers it automatically on Headscale and announces its zone’s subnet. Two manual steps remain: provide an attach key in sops (before deployment) and approve the routes on the HCS (after).

  1. On the HCS: generate an attach key

    Fenêtre de terminal
    just enter hcs
    sudo headscale users list # ID of the nix user
    sudo headscale preauthkeys create --reusable --expiration 1h --user <id>

    Copy the displayed key (tskey-...). It only needs to live long enough for the deployment.

  2. Store the key in secrets

    Fenêtre de terminal
    just sops # opens usr/secrets/secrets.yaml

    Set the key under tailscale/authKey:

    usr/secrets/secrets.yaml
    tailscale:
    authKey: "tskey-..."
  3. Deploy the gateway

    Fenêtre de terminal
    just apply gw

    The gateway registers itself automatically. Internally, the daemon runs:

    Fenêtre de terminal
    tailscale up \
    --login-server https://headscale.domain.tld \
    --auth-key file:/run/secrets/tailscale/authKey \
    --advertise-routes 10.0.0.0/16 --accept-routes --reset
  4. On the HCS: approve the announced routes

    Fenêtre de terminal
    sudo headscale nodes list # ID of the gateway
    sudo headscale nodes approve-routes --identifier <id> --routes 10.0.0.0/16
  5. Declare the gateway’s tailnet IP

    At registration, Headscale assigns the gateway a tailnet IP (100.64.x.y), visible in headscale nodes list. Record it in etc/config.yaml: other nodes reach the zone (internal DNS, replication, monitoring) via this address.

    etc/config.yaml
    zones:
    maison:
    gateway:
    vpn:
    ipv4: "100.64.0.8"

    Then regenerate and redeploy the affected nodes (the HCS and the zone’s clients) to propagate the address.

The headscale command (alias h = sudo headscale) on the HCS:

Fenêtre de terminal
sudo headscale users list # users
sudo headscale nodes list # connected clients
sudo headscale nodes routes # advertised / approved routes

A tailscaled can silently disconnect from Headscale: the control connection is lost while the service remains “active”, and a gateway’s subnet becomes unreachable until a manual restart. A local watchdog detects and fixes this, on every Tailscale client.

AspectBehavior
Probeevery 60 s: tailscale status (backend, online state, health)
Debounce3 consecutive probe failures (~3 min) before acting: tolerates transient WAN outages
Fixsystemctl restart tailscaled (automatic re-registration on Headscale)
Anti-looponly one fix per 10 min window
Observabilitymetrics dnf_tailscale_healthy and dnf_tailscale_selfheal_restarts_total (textfile node_exporter)

Two alerts accompany the mechanism:

  • TailscaleUnhealthy: stuck unhealthy despite the fix (underlying issue);
  • TailscaleFlapping: self-heal triggers too often (root cause to address).

The Headscale server name normally resolves through the tailnet: on a gateway, dnsmasq routes the whole network domain to the tailnet’s DNS server. The client would therefore need the VPN to reach the server that brings up the VPN — and at boot, the local resolver is not listening yet.

Each Tailscale client therefore receives the HCS public IP in its /etc/hosts, deduced from etc/config.yaml (the HCS itself is excluded):

Fenêtre de terminal
grep headscale /etc/hosts # 217.182.207.26 headscale.example.org

The client thus needs no resolver to connect, even when the local DNS is down — precisely when the self-repair mechanism above must be able to act.

To join the network from outside a zone, see Connecting from outside.