An Ansible role that installs and configures Tailscale on Fedora with a deny-by-default security posture. No DNS takeover, no default-route changes, shields up, a single scoped firewalld change — and every loosening is an explicit per-host opt-in. Works against Tailscale's hosted control plane or a self-hosted Headscale coordinator (see the companion headscale-ansible).
The project ships with a single role (tailscale) and is structured to grow —
adding further roles to the same repository follows the conventions described
below.
- Upstream packaging. Installs from Tailscale's signed Fedora repository, not
distribution packages; the GPG key is verified for both metadata and packages
(
gpgcheck=1,repo_gpgcheck=1). - No DNS side-effects.
--accept-dns=false;/etc/resolv.confchecksum is snapshotted pre-apply and asserted unchanged post-apply. - No routing side-effects.
--accept-routes=false; the host default route is snapshotted pre-apply and asserted unchanged post-apply. Any drift fails the play with a before/after diff. - Deny-by-default inbound.
--shields-up; tailnet peers cannot reach the host until explicitly allowed. - Exit node / subnet router as an explicit opt-in. Advertising is off by
default. Opting in also configures what routing actually needs, declared in the
role rather than left to chance: kernel forwarding sysctls,
tailscale0in the firewalldtrustedzone, masquerade on the egress zone, and a trusted→ANY ACCEPT policy (Fedora's FORWARD chain otherwise rejects NEW connections from the trusted zone). - Self-hosted coordinator support. Set
tailscale_login_serverto enrol against a Headscale instance instead oflogin.tailscale.com. - Scoped firewall changes, no reloads. Exactly one UDP port is opened, in the
firewalld zone that actually holds the default-route interface — discovered at
runtime, never assumed. The role never runs
firewall-cmd --reload: a reload silently drops runtime-only state such as podman/netavark's container-subnet sources, and that breakage looks nothing like its cause. - Vaulted credentials. The auth key comes from an
ansible-vaultencrypted file; every task that touches it setsno_log. - Rootless CLI.
--operator=<user>lets a local user runtailscalewithout sudo for status/diagnostics. - Idempotent. A clean second apply reports zero changes.
tailscale upruns only when the backend is not alreadyRunning; otherwisetailscale setreconciles configuration drift. - Standalone verification. A read-only
verify.ymlplaybook asserts the posture at any time without modifying state.
ansible-core2.15 or newer- Collections:
ansible.posix,community.general(installed viarequirements.yml)
- Fedora (tested on Fedora 43)
systemdfirewalldas the active firewall manager- Python 3 available to Ansible
- A user account with
sudoprivileges (the playbook escalates per-task; no play-levelbecome)
Clone the repository, then perform the steps below on the control node.
ansible-galaxy collection install -r requirements.ymlumask 077
printf '%s' 'your-strong-passphrase' > .vault_pass
chmod 600 .vault_passStore the passphrase in a password manager. If it is lost, the encrypted vault contents are unrecoverable.
cat > inventory.ini <<'EOF'
[tailscale_nodes]
node1 ansible_host=<ip-or-hostname> ansible_user=<user>
EOFFor a local-only setup, use ansible_connection=local.
inventory.ini, group_vars/, and host_vars/ are gitignored. Real hostnames,
domains, and IPs live only there — tracked files carry placeholders.
Only required if a default does not suit your environment. Typical overrides are the UDP listen port and, for Headscale users, the coordinator URL:
mkdir -p group_vars/all
cat > group_vars/all/vars.yml <<'EOF'
---
tailscale_listen_port: 41641 # pick any valid UDP port
# tailscale_login_server: "https://headscale.example.com"
EOFSee Role variables for the full list.
Hosted Tailscale: create a pre-authorized, non-reusable, non-ephemeral key in the admin console. Use the shortest practical expiration.
Headscale: mint a pre-auth key on the coordinator
(headscale preauthkeys create --user <id> --expiration 24h).
ansible-vault create group_vars/all/vault.ymlFile contents:
tailscale_authkey: tskey-auth-XXXXXXXXXXXXXXXXXXXXXXXXXXansible-playbook site.yml --check --diff # dry run
ansible-playbook site.yml # apply
ansible-playbook verify.yml # read-only verification
ansible-playbook site.yml # must report changed=0Supplied via the encrypted vault (group_vars/all/vault.yml by convention).
| Variable | Purpose |
|---|---|
tailscale_authkey |
Pre-authorized auth key from the admin console or Headscale. |
Defined and documented in roles/tailscale/defaults/main.yml. Override in
group_vars/all/vars.yml or per-host host_vars/<hostname>.yml.
| Variable | Default | Description |
|---|---|---|
tailscale_listen_port |
41641 |
UDP port. Privileged ports below 1024 are rejected; IANA dynamic range 49152–65535 is recommended for defense-in-depth. |
tailscale_login_server |
"" |
Coordination server. Empty uses hosted Tailscale; set an https:// Headscale URL to self-host. |
tailscale_operator_user |
{{ ansible_user_id }} |
Local user permitted to run tailscale CLI without sudo. |
tailscale_hostname |
{{ inventory_hostname_short }} |
Device name displayed in the admin console. |
tailscale_firewalld_zone |
"" |
Empty string triggers auto-discovery based on the interface holding the default route. |
tailscale_accept_routes |
false |
Refuse to install subnet routes advertised by peers. |
tailscale_accept_dns |
false |
Never modify the host's DNS configuration. |
tailscale_advertise_exit_node |
false |
Advertise this node as an exit node. Enables forwarding + firewalld routing pieces. |
tailscale_advertise_routes |
[] |
Subnet CIDRs to advertise. Non-empty enables the same routing pieces. |
tailscale_ssh |
false |
Disable Tailscale SSH; OpenSSH remains the only SSH implementation on the host. |
tailscale_shields_up |
true |
Block all inbound connections from the tailnet until explicitly allowed. Overrides coordinator ACLs. |
tailscale_reset_on_first_run |
false |
Pass --reset to the first tailscale up. Wipes prior daemon state — for clean reinstalls only. |
tailscale_repo_name |
tailscale-stable |
DNF repository name. |
tailscale_repo_baseurl |
https://pkgs.tailscale.com/stable/fedora/$basearch |
Upstream repository base URL. |
tailscale_repo_gpgkey |
https://pkgs.tailscale.com/stable/fedora/repo.gpg |
GPG key URL used for both gpgcheck and repo_gpgcheck. |
Minimal consumer playbook (applied automatically by this project's site.yml):
- hosts: tailscale_nodes
gather_facts: true
become: false
roles:
- role: tailscaleWith overrides at the play level:
- hosts: tailscale_nodes
roles:
- role: tailscale
vars:
tailscale_listen_port: 52187
tailscale_hostname: edge-1
tailscale_advertise_routes:
- 10.20.0.0/24verify.yml is a read-only playbook that can be run at any time. It asserts:
tailscale status --jsonreportsBackendState == RunningandSelf.Online.- The default route is unchanged since the last apply (inline check only; a standalone run asserts current validity rather than pre/post drift).
/etc/resolv.confchecksum is unchanged.- The configured UDP port is open in the resolved firewalld zone.
tailscaledis bound to the configured UDP port.
ansible-playbook verify.ymlDisconnect from the tailnet without uninstalling:
sudo tailscale downConfirm the host's networking is unchanged:
ip route show default
cat /etc/resolv.confOptionally remove the machine from the tailnet via the admin console.
Full uninstall:
IFACE="$(ip -json route show default | python3 -c 'import json,sys;print(json.load(sys.stdin)[0]["dev"])')"
ZONE="$(firewall-cmd --get-zone-of-interface="$IFACE")"
PORT=<configured tailscale_listen_port>
sudo systemctl disable --now tailscaled
sudo dnf remove -y tailscale
sudo firewall-cmd --zone="$ZONE" --remove-port="$PORT/udp" --permanent
sudo firewall-cmd --zone="$ZONE" --remove-port="$PORT/udp"
sudo rm -f /etc/yum.repos.d/tailscale-stable.repo
sudo rm -rf /etc/systemd/system/tailscaled.service.dOn an exit-node/router host, also remove the opt-in routing pieces:
sudo firewall-cmd --zone=trusted --remove-interface=tailscale0 --permanent
sudo firewall-cmd --zone=trusted --remove-interface=tailscale0
sudo firewall-cmd --zone="$ZONE" --remove-masquerade --permanent
sudo firewall-cmd --zone="$ZONE" --remove-masquerade
sudo firewall-cmd --permanent --delete-policy=tailscale-egress
sudo rm -f /etc/sysctl.d/99-tailscale.conf- Expire the current key in the admin console (or
headscale preauthkeys expire). - Generate a replacement pre-authorized key with the same tags.
ansible-vault edit group_vars/all/vault.ymland updatetailscale_authkey.- On the managed node:
sudo tailscale logoutto force re-authentication. ansible-playbook site.yml— the role detects the logged-out state and runstailscale upwith the new key exactly once.
ansible-vault rekey group_vars/all/vault.yml
# Update .vault_pass with the new passphrase.The tracked repo is reproducible. The gitignored files are not:
group_vars/all/{vars,vault}.yml, inventory.ini, host_vars/, and
.vault_pass. Back them up outside git. Keep .vault_pass only in a password
manager — a backup that bundles the vault with its password defeats the
encryption. The node's tailnet identity (/var/lib/tailscale/) is re-creatable:
re-authenticate with a fresh pre-auth key.
Install the Tailscale client on macOS/iOS/Android/Windows/Linux and sign in
to the same tailnet identity used to generate the auth key. The configured
device will appear under Machines in the admin console with the hostname
set by tailscale_hostname.
To verify a direct (unrelayed) connection from a client:
tailscale ping <hostname>A response of the form pong from <host> via <ip>:<port> indicates a direct
UDP connection. via DERP(<region>) indicates the connection is being
relayed, typically because the client-side network blocks outbound UDP to
the configured port or uses symmetric NAT.
| Symptom | Resolution |
|---|---|
tailscale_authkey is empty during preflight |
Vault is not being decrypted. Verify .vault_pass exists and matches the passphrase used for group_vars/all/vault.yml. |
Resolved firewalld zone is empty during firewall |
The default-route interface is not assigned to a firewalld zone. Pin the zone explicitly with tailscale_firewalld_zone: <zone>. |
Default route changed after Tailscale apply |
The route was rewritten despite --accept-routes=false. Immediately run sudo tailscale down and investigate with ip rule, ip route show table all. |
/etc/resolv.conf changed |
Something modified DNS despite --accept-dns=false. Check systemctl status systemd-resolved and resolvectl status. |
Second site.yml run reports changed > 0 |
A non-idempotent task fired. Most likely tailscale up ran twice; verify tailscale_already_up was evaluated correctly (see configure.yml). |
tailscaled is not bound to the configured UDP port |
The systemd drop-in was not loaded. sudo systemctl daemon-reload && sudo systemctl restart tailscaled. |
| Peers cannot open NEW connections through the exit node | The tailscale-egress policy is missing. Fedora's FORWARD chain rejects new trusted→other-zone connections; re-apply with exit-node advertising enabled. |
- Secrets management. Auth keys and other sensitive values are stored only
in
ansible-vaultencrypted files..vault_passis gitignored. Tasks that handle the key setno_log: true. - Privilege escalation. The play does not set
becomeglobally; it is enabled per-task, only on the subset of tasks requiring root. - Firewall changes. One UDP port, opened with
ansible.posix.firewalldin a zone discovered at runtime. The exit-node routing pieces apply only when advertising is enabled. Neverfirewall-cmd --reload. - System integrity checks. The default route and
/etc/resolv.confare snapshotted before apply and verified unchanged after apply. - Upstream packaging. The Tailscale package is installed from the vendor's signed Fedora repository; the GPG key is verified on both metadata and package signatures.
CI gates on the production profile of
ansible-lint plus playbook syntax
checks. Locally:
ansible-lintIf your distribution's ansible-core is older than what recent ansible-lint
releases require, use a project-local virtualenv:
python3 -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install 'ansible-core>=2.15' 'ansible-lint>=25'
.venv/bin/ansible-lintDeploys continue to use the system ansible-playbook; the venv is for lint only.
mkdir -p roles/<role>/{defaults,tasks,handlers,meta,vars,templates}- Populate
defaults/main.ymlwith tunables using deny-by-default values. - Write
tasks/main.yml; scopebecome: trueto individual tasks. - Add read-only assertions to a
tasks/verify.ymlfile. - Add the role to
site.ymland document it in this README.
- Every role is idempotent; a second apply must report
changed=0. - Secrets are declared in
defaults/main.ymlas empty strings and sourced from vault-resolved variables; preflight fails loudly on empty values. - State-changing tasks that touch secrets set
no_log: true. - Check mode (
--check) is supported; read-only facts that must run in check mode setcheck_mode: falseexplicitly.
ansible.cfg # inventory path, roles_path, vault_password_file
requirements.yml # Galaxy collection dependencies
site.yml # apply playbook
verify.yml # read-only verification
roles/tailscale/
defaults/main.yml # all tunables, fully commented — the reference
vars/main.yml # internal constants
tasks/
main.yml # orchestration
preflight.yml # snapshot state, validate inputs
install.yml # repo, GPG key, package, service
systemd_override.yml # pin listen port via drop-in
resolve_zone.yml # read-only firewalld zone discovery
firewall.yml # open UDP port; exit-node routing pieces
forwarding.yml # kernel forwarding sysctls (exit/router only)
configure.yml # tailscale up / tailscale set
verify.yml # post-apply assertions
handlers/main.yml # restart tailscaled
meta/main.yml # Galaxy metadata
inventory.ini # gitignored — environment-specific
group_vars/all/ # gitignored — environment-specific
vars.yml # plaintext overrides
vault.yml # ansible-vault encrypted secrets
host_vars/ # gitignored — optional per-host overrides
.vault_pass # gitignored — vault passphrase
Issues and PRs welcome — see CONTRIBUTING.md and CODE_OF_CONDUCT.md. This is a personal project maintained on a best-effort basis. Security reports: SECURITY.md.
MIT.