How-To Guide

Set Up and Operate the Headscale VPN

Goal: Operate the self-hosted WireGuard mesh VPN (Headscale) on vpn.kup.tirol and onboard new devices.

Time: ~10 minutes per device

Prerequisites

  • SSH access as root to vpn.kup.tirol (Hetzner project vpn, hcloud context kupvpn)

  • Repo checkout with the config sources in vpn-headscale/

  • For provisioning history and exact resource IDs, see vpn-headscale/PROVISIONING.md in the repo root

Architecture in one paragraph

Headscale (native .deb, systemd) on a dedicated Hetzner VM is the coordination server for official Tailscale clients. The embedded DERP relay (TCP 443) and STUN (UDP 3478) solve NAT traversal for peers without static IPs. Traffic between devices is end-to-end WireGuard; the server only distributes keys, routes, and ACLs. MagicDNS names use the suffix tail.kup.tirol. Design and phases: docs/superpowers/specs/2026-06-08-headscale-vpn-design.md.

Check service health

curl https://vpn.kup.tirol/health
ssh root@vpn.kup.tirol 'systemctl status headscale --no-pager'
ssh root@vpn.kup.tirol 'journalctl -u headscale -f'

Expect HTTP 200 and active (running).

Onboard a Linux or macOS device

Create a user once per person, then a short-lived pre-auth key:

ssh root@vpn.kup.tirol 'headscale users create <name>'
ssh root@vpn.kup.tirol 'headscale users list'
ssh root@vpn.kup.tirol 'headscale preauthkeys create --user <ID> --expiration 1h'

Important

headscale preauthkeys create --user requires the numeric user ID. headscale auth register --user requires the user name. This CLI inconsistency exists in Headscale 0.29.

Join the device with the official Tailscale client:

sudo tailscale up --login-server https://vpn.kup.tirol --authkey <key> --hostname <device-name>

Verify the registration:

ssh root@vpn.kup.tirol 'headscale nodes list'
tailscale status

Onboard a Windows device

The Windows client sets the coordination server through its command line interface, not through the GUI.

  1. Install the Tailscale client from https://tailscale.com/download/windows and let it start. Do not sign in through the tray icon, because that flow targets the Tailscale SaaS.

  2. Open PowerShell and start the login against the Headscale server:

    tailscale login --login-server https://vpn.kup.tirol
    
  3. The browser opens the registration page and shows an hskey-authreq-… value. The same value also appears in the Headscale journal.

  4. Register the device on the server:

    ssh root@vpn.kup.tirol 'headscale auth register --auth-id <hskey-authreq-...> --user <name>'
    

The login server persists in the client profile, so this setup is a one-time step.

Onboard a mobile device

  1. Install the Tailscale app.

  2. Open the gear icon, select Accounts, then the three-dot menu, then Use an alternate server.

  3. Enter https://vpn.kup.tirol and sign in.

  4. The browser shows a command containing an hskey-authreq-… value.

  5. Register the device on the server:

ssh root@vpn.kup.tirol 'headscale auth register --auth-id <hskey-authreq-...> --user <name>'

Onboard a Home Assistant OS device

Home Assistant OS joins the mesh through the Tailscale add-on. The node name comes from the Home Assistant hostname, which is homeassistant by default.

  1. Install the Tailscale add-on from the add-on store.

  2. In the add-on configuration, set Login server to https://vpn.kup.tirol and keep all other options at their defaults. Keep Log upload off, because it sends client logs to log.tailscale.com. Keep Share Home Assistant with Serve or Funnel disabled, because both features require the Tailscale SaaS control plane, and Funnel would expose the instance publicly.

  3. Save the configuration and restart the add-on. The add-on then requests interactive registration, and the auth id appears in the Headscale journal:

    ssh root@vpn.kup.tirol 'journalctl -u headscale --since "-10 min" | grep authreq'
    

    Alternatively, open the add-on web UI and follow the login link to read the same hskey-authreq-… value.

  4. Register the node on the server:

    ssh root@vpn.kup.tirol 'headscale auth register --auth-id <hskey-authreq-...> --user <name>'
    
  5. Grant group access in the ACL. Add the node’s tailnet IP as a hosts entry in vpn-headscale/config/acl.hujson and allow the audience group on the Home Assistant port, for example group:familie to homeassistant:8123. Deploy the policy as described in Change configuration or ACLs.

Clients reach Home Assistant at http://homeassistant.tail.kup.tirol:8123. Plain HTTP is acceptable here because the transport between the nodes is WireGuard-encrypted.

Verify mesh connectivity

tailscale ping <other-device-ip-or-name>

direct <ip:port> means peer-to-peer WireGuard works. via DERP(headscale) means traffic uses the relay on the VM; this is the expected fallback behind strict NAT. MagicDNS check:

ping <device-name>.tail.kup.tirol

Change configuration or ACLs

Always edit the files in the repo (vpn-headscale/config/), never only on the VM. Validate the policy before activating it:

scp vpn-headscale/config/acl.hujson root@vpn.kup.tirol:/tmp/acl-candidate.hujson
ssh root@vpn.kup.tirol 'headscale policy check --file /tmp/acl-candidate.hujson'
ssh root@vpn.kup.tirol 'install -m 640 -o headscale -g headscale /tmp/acl-candidate.hujson /etc/headscale/acl.hujson && systemctl restart headscale'

In policy v2, user references require an @ suffix, for example jens@.

Update Headscale

OS security updates install automatically via unattended-upgrades (reboot window 04:30 UTC). The systemd timer headscale-autopatch checks daily around 04:00 UTC:

  • Patch releases (same minor) install automatically with a database backup and health-check rollback.

  • Minor and major releases only trigger a notification mail to jk@kleinundpartner.at.

For a manual (minor/major) upgrade, read the release notes first, then:

ssh root@vpn.kup.tirol 'cp -a /var/lib/headscale/db.sqlite /var/lib/headscale/db.sqlite.bak-manual'
HEADSCALE_VERSION=<x.y.z> ssh root@vpn.kup.tirol 'HEADSCALE_VERSION=<x.y.z> bash /root/install-headscale.sh'
ssh root@vpn.kup.tirol 'systemctl restart headscale && sleep 5 && curl -fsS https://vpn.kup.tirol/health'

Compare the config schema after minor upgrades:

ssh root@vpn.kup.tirol 'diff /etc/headscale/config.yaml.dist /etc/headscale/config.yaml'

Troubleshooting

TLS handshake errors with mkdir /var/www: read-only file system

The ACME cache directory is not writable. Ensure tls_letsencrypt_cache_dir: /var/lib/headscale/cache is set in config.yaml.

Certificate not issued

Let’s Encrypt HTTP-01 needs port 80 reachable from the internet. Check both the Hetzner firewall vpn-headscale-fw and ufw status on the VM.

Ping only works via DERP, never direct

Hole punching failed, which some NATs cause. This is functional but adds latency; traffic stays end-to-end encrypted.

Policy parse errors after an ACL change

Run headscale policy check --file <candidate> before activating. Headscale does not start with an invalid policy file.

Notification mails missing

Check /var/log/msmtp.log on the VM. The Mailjet credentials live only in /etc/msmtprc (mode 600) on the VM.