This Ansible role deploys Headscale as a rootful Podman Quadlet on Fedora Server behind a Traefik reverse proxy. Headscale provides an open-source coordination server for Tailscale clients. It manages WireGuard public keys and network policies while client VPN tunnels remain direct and peer-to-peer.
The host executing this role acts as the control plane only. It does not join the tailnet as a client device.
- Hardened Podman Quadlet: Runs containerized with
ReadOnly,DropCapability=all,NoNewPrivileges, non-root execution (UID 65532), and memory limits. - Traefik Integration: Generates dynamic routing configuration using the
h2c://scheme to preserve gRPC protocol traffic across the proxy. - Health-Gated Deploys: Snapshots the SQLite database before version updates and tests health endpoints after restarts. Reverts automatically to the previous image if health checks fail.
- Self-Healing Watchdog: Runs a local systemd timer to verify external availability and execute recovery steps without manual intervention.
- Firewall Isolation: Enforces mangle PREROUTING rules in nftables to drop direct traffic from outside the container network, keeping the backend behind the proxy.
- Routing Stability: Snapshots default routing tables and
/etc/resolv.confbefore execution and validates them after apply. - Safe Upgrades: Includes an upgrade helper script that reports new releases and automates patch updates safely.
- Idempotency: Guarantees zero changes on repeated playbook runs.
- Fedora Server with Podman and firewalld.
- Traefik configured to watch a dynamic configuration directory, with valid TLS certificates for your domain.
- A public or private DNS record pointing your Headscale domain to the host.
ansible-galaxy collection install -r requirements.ymlumask 077
printf '%s' 'your-vault-passphrase' > .vault_pass
chmod 600 .vault_passCreate inventory.ini:
[headscale_nodes]
server.example.com ansible_connection=local ansible_host=127.0.0.1 ansible_python_interpreter=/usr/bin/python3Create group_vars/all/vars.yml:
---
headscale_server_url: "https://headscale.example.com"
headscale_domain: "headscale.example.com"
headscale_dns_magic_domain: "ts.example.com"
headscale_host_ip: "192.0.2.10"
headscale_traefik_dynamic_dir: "/path/to/traefik/dynamic"
headscale_admin_user: "admin"All inventory files, variables, and vault credentials remain gitignored to keep sensitive network details out of version control.
ansible-vault create group_vars/all/vault.ymlansible-playbook site.yml --check --diff
ansible-playbook site.yml
ansible-playbook verify.yml
ansible-playbook site.ymlThe second run must report changed=0.
Headscale generates its own device credentials using pre-authenticated keys. All server administrative commands execute inside the container:
sudo podman exec headscale headscale <command>Create a user account to manage devices:
sudo podman exec headscale headscale users list
sudo podman exec headscale headscale users create alice --email alice@example.comNote the numeric user ID returned by users list. Key generation commands use this ID.
Generate a single-use authentication key valid for 24 hours:
sudo podman exec headscale headscale preauthkeys create --user 1 --expiration 24h| Option | Description |
|---|---|
--user <id> |
Numeric ID of the owning user. |
--expiration <dur> |
Duration before key expires (for example 1h, 24h). |
--reusable |
Permits multiple devices to authenticate with the same key. |
--ephemeral |
Discards nodes automatically when they disconnect. |
--tags <tag,...> |
Assigns ACL tags to the node. |
List or expire keys:
sudo podman exec headscale headscale preauthkeys list
sudo podman exec headscale headscale preauthkeys expire --id 5Configure client software to use your self-hosted server:
- Linux:
sudo tailscale up --login-server https://headscale.example.com --authkey <key>
- macOS (CLI):
tailscale up --login-server https://headscale.example.com --authkey <key>
- iOS / iPadOS:
- Open the Tailscale app.
- Tap the user icon, then select Log in….
- Select Use custom coordination server from the menu.
- Enter
https://headscale.example.com. - Enter the pre-auth key when prompted.
Verify registered nodes on the server:
sudo podman exec headscale headscale nodes listAuthoritative defaults and descriptions are documented in defaults/main.yml.
| Variable | Description |
|---|---|
headscale_server_url |
Public HTTPS URL for this Headscale instance. |
headscale_domain |
Hostname used in Traefik routing rules. |
headscale_dns_magic_domain |
MagicDNS search domain for tailnet peers. |
headscale_host_ip |
Host IPv4 address used by Traefik to reach the container. |
headscale_traefik_dynamic_dir |
Directory path where Traefik reads dynamic routing files. |
headscale_admin_user |
Username owning operator nodes, used in ACL auto-approvers. |
| Variable | Default | Description |
|---|---|---|
headscale_version |
0.29.3 |
Container image tag. |
headscale_image |
ghcr.io/juanfont/headscale |
Container image repository. |
headscale_host_port |
8080 |
Port published on headscale_host_ip. |
headscale_metrics_host_port |
9091 |
Prometheus metrics port bound to 127.0.0.1. |
headscale_config_dir |
/mnt/config/headscale |
Host path for configuration files. |
headscale_data_dir |
/mnt/config/headscale/data |
Host path for SQLite database storage. |
headscale_magic_dns |
false |
Enables MagicDNS support. |
headscale_dns_global_nameservers |
[1.1.1.1, 1.0.0.1] |
Upstream DNS resolvers pushed to clients. |
headscale_dns_extra_records |
[] |
Extra DNS records served to tailnet devices. |
headscale_trusted_network |
10.88.0.0/16 |
Subnet permitted to access the backend port directly. |
headscale_trusted_proxy_networks |
[] |
Additional subnets permitted for multi-homed proxy setups. |
headscale_watchdog_enabled |
true |
Installs and activates the reachability watchdog timer. |
headscale_container_uid |
65532 |
UID used for container execution and file permissions. |
Playbook execution manages service updates safely:
- Captures the active container image identifier before making changes.
- Creates an online SQLite database backup before pulling new image versions.
- Renders updated configuration and restarts the container unit.
- Executes internal and external health checks.
- If health checks fail, the rescue block restores the previous image tag, restarts the service, and reports the error.
The headscale-watchdog.timer unit triggers every 2 minutes to verify external service reachability.
The script runs locally and depends on no external services. If the endpoint becomes unreachable, the watchdog classifies the failure:
- Host connectivity issues and TLS certificate errors are reported without restarting containers, avoiding loops caused by network link loss.
- Container routing or bridge failures trigger sequential recovery steps: reloading container firewall rules, optionally restarting the proxy, and restarting Headscale.
- Bounded retry limits prevent flapping by limiting repair attempts to 3 times per hour.
The nftables mangle PREROUTING rule in firewall.yml drops traffic directed to headscale_host_ip:headscale_host_port from outside headscale_trusted_network.
By default, headscale_trusted_network permits the container bridge subnet (10.88.0.0/16). Netavark assigns dynamic IP addresses to containers on restart. Allowing the bridge subnet prevents startup failures caused by IP address changes, while completely blocking direct access from external LAN and WAN clients.
Check for updates or apply patch releases:
scripts/headscale-upgrade.sh # Check available versions
scripts/headscale-upgrade.sh --apply # Apply verified patch updatesBecause Headscale database migrations are forward-only, minor and major updates require manual verification. Review upstream release notes, create a database snapshot, update headscale_version, and run the deployment loop.
Run read-only verification checks at any time:
ansible-playbook verify.ymlThe playbook validates that:
- The Headscale container is active.
- Internal
/healthreturns HTTP 200. - External
headscale_server_url/healthreturns HTTP 200. - Traefik dynamic routing files exist.
- System routing tables and
/etc/resolv.confremain unmodified.
To remove Headscale:
sudo systemctl disable --now headscale-watchdog.timer
sudo rm /etc/systemd/system/headscale-watchdog.{service,timer} /usr/local/bin/headscale-watchdog.sh
sudo systemctl stop headscale
sudo systemctl disable headscale
sudo rm /etc/containers/systemd/headscale.container
sudo systemctl daemon-reload
sudo rm <headscale_traefik_dynamic_dir>/headscale.ymlConfiguration files and database state in /mnt/config/headscale remain on disk until deleted manually.
Contributions and issue reports are welcome. See CONTRIBUTING.md and CODE_OF_CONDUCT.md for details. Report security vulnerabilities according to SECURITY.md.
This project is licensed under the MIT License.