Skip to content

Headscale

For daily operations in Odoo, see node management.

roles.control_plane includes headscale, control_proxy and control_vpn. Apply it with local Salt from /srv/oduflow before enrolling client minions. Headscale uses the official 0.29.3 amd64 DEB; the package SHA256 and matching configuration are pinned in the state.

Set the coordination URL, for example https://headscale.example.com. Its Cloudflare record must remain DNS only: Tailscale uses HTTP POST with the tailscale-control-protocol Upgrade, which Cloudflare Proxy/Tunnel does not support. This restriction concerns the coordination endpoint; application routing is a separate decision.

Installation

Requirements: Debian/Ubuntu amd64, installed Salt and a checkout at /srv/oduflow. Bootstrap masks headscale.service before first installation. The state installs configuration/policy, then the package, validates configuration, unmasks and enables the service. This ordering prevents the package's postinst from starting an unconfigured service. Its data directory is explicitly owned by headscale.

salt-call --local --file-root=/srv/oduflow/salt/states \
  state.apply roles.control_plane
headscale configtest
headscale policy check --file /etc/headscale/policy.json
systemctl status headscale

config.yaml and policy contain no secrets and are root:root 0644. Noise keys, SQLite and runtime files live under /var/lib/headscale, owned by headscale:headscale with directory mode 0750; the packaged service uses umask 0077. The Unix socket is available to root and the headscale group. Do not add the Salt API account to that group.

Headscale binds 127.0.0.1:8080; local Traefik terminates TLS on 443. Metrics 9090 and administrative gRPC 50443 stay on loopback. The proxy preserves HTTP/1.1 Upgrade, POST and long connections, and replaces client-address headers with the actual peer information. trusted_proxies allows only loopback. Administration API keys are provisioned explicitly for the integrations that need them.

VPN access policy

Addresses are assigned sequentially from 100.64.0.0/10. MagicDNS and resolv.conf replacement are disabled; clients use --accept-dns=false. Embedded DERP is disabled and the public Tailscale relay map supplies fallback connectivity. Outbound access to those relays is required; a private DERP is a separate option.

The policy permits only these new TCP connections:

Source Destination Ports Purpose
tag:client tag:master 4505, 4506 Salt minion connections
tag:odoo tag:master 8000 Salt HTTPS API
tag:master tag:odoo 443 Odoo external pillar
tag:client, tag:odoo tag:llm 4000 LiteLLM HTTP over WireGuard

Other connections, including client-to-client and client-to-Salt-API, are denied. LiteLLM inference and management share port 4000. LiteLLM authorization restricts client keys to inference routes; only Control Odoo receives the management key. See LiteLLM VPN setup for host configuration and verification. Tailscale handles return traffic. Salt uses connections initiated by minions, so a separate master-to-client connection rule is unnecessary. Host firewalls and VPN-only Salt binding provide additional restrictions.

control_vpn pins Tailscale 1.102.4 from the signed Ubuntu resolute repository and additionally verifies the apt public key by SHA256. It starts tailscaled but does not enroll the node or consume enrollment secrets. UFW allows TCP 22/80/443, UDP 41641 and TCP 4505/4506/8000 only on tailscale0. Existing rules and default policy are retained. Bootstrap must first enable UFW with default incoming DROP/REJECT and SSH allowed; the helper refuses an inactive/open-default firewall. Repeated application does not duplicate rules. Tailscale creates its own netfilter chains, so test actual Headscale ACL enforcement between live nodes.

tagOwners is intentionally empty: users cannot self-assign infrastructure tags through --advertise-tags. An administrator assigns tags using local CLI or one-time tagged preauth keys. Persistent servers do not use ephemeral keys and need no reusable enrollment keys. Tagged nodes do not automatically expire their node keys, so client deletion must explicitly remove them from Headscale.

Create a separate short-lived enrollment key for each node:

# Run in a root shell; this secret file is outside the checkout.
umask 077
headscale preauthkeys create --tags tag:master --expiration 10m \
  --output json > /run/oduflow-master-enrollment.json

Pass its key through a protected file to bootstrap, join with tailscale up --login-server=https://headscale.example.com --accept-dns=false --hostname=control-master and the file-backed auth key, then remove temporary files. Never put the key in Salt output, Git or shared runner arguments. Create separate tag:odoo and tag:client keys similarly. Check assigned tags in headscale nodes list; obtain actual addresses there or with tailscale ip -4, rather than assuming enrollment order.

Headscale enrollment does not accept a Salt minion key. Salt separately requires a generated keypair and fingerprint verification: master, minion.

Validation and maintenance

headscale configtest and headscale policy check were tested with the real 0.29.3 binary and temporary SQLite data. These validate format; deployed VPN/TLS and ACL behavior require live-node checks. /health alone does not prove VPN connectivity. Current live evidence is in the deployment journal.

The automated master backup and recovery workflow is described in master automation. It creates a SQLite Backup API copy, verifies database integrity, and preserves the associated control-host identities.

For a manual consistent backup, stop Headscale and preserve /etc/headscale together with all of /var/lib/headscale, including keys and SQLite WAL. Restoring only the primary SQLite file without a consistent WAL is unsafe. Repeated bootstrap must not overwrite existing state. Version upgrades require migration review and a newly verified package checksum.

Sources: release 0.29.3, release configuration, reverse proxy, tags, policy.