Router Tailscale Role Design
Bead: selfhosted-cluster-gv9 Date: 2026-01-18
Overview
Section titled “Overview”Create an Ansible role to manage Tailscale on the Firewalla router, following patterns from router-vault-unseal and alloy roles.
Current State
Section titled “Current State”Tailscale runs on the router but was deployed manually:
- Image:
ghcr.io/tailscale/tailscale:latest(unpinned) - Config:
/home/pi/.firewalla/run/docker/tailscale/docker-compose.yml - State:
/extdata/tailscale-data - Issues:
- No boot hook (relies on
restart: unless-stopped) - Auth key passed via environment variable manually
- Version unpinned
- No boot hook (relies on
Design
Section titled “Design”Auth Key Handling
Section titled “Auth Key Handling”Constraint: Minimize router dependencies on Vault for core services.
Approach: SOPS-encrypted file with age encryption, matching vault-unseal pattern.
tailscale_auth_key: "tskey-auth-xxxxx"Key behavior: Auth keys are only needed on first connection. Once registered, state in /extdata/tailscale-data persists the identity. The role checks for existing state before loading the auth key.
Role Structure
Section titled “Role Structure”ansible/roles/router-tailscale/├── defaults/main.yml # Configurable defaults├── handlers/main.yml # Restart handler├── meta/main.yml # Role metadata├── tasks/│ ├── main.yml # Load SOPS, validate, orchestrate│ ├── directories.yml # Create /extdata structure│ ├── docker.yml # Deploy compose file│ └── boot-script.yml # post_main.d script└── templates/ ├── docker-compose.yml.j2 └── boot-script.sh.j2Configuration Defaults
Section titled “Configuration Defaults”tailscale_base_dir: "/extdata/tailscale-data"tailscale_image: "ghcr.io/tailscale/tailscale"tailscale_version: "v1.92.5"
tailscale_hostname: "router"tailscale_advertise_exit_node: truetailscale_advertise_routes: - "192.168.0.0/16" - "10.0.0.0/8"tailscale_userspace: false
# Firewalla pathstailscale_docker_compose_dir: "/home/pi/.firewalla/run/docker/tailscale"tailscale_post_main_dir: "/home/pi/.firewalla/config/post_main.d"
# SOPS file pathtailscale_sops_file: "{{ playbook_dir }}/secrets/router-tailscale.sops.yaml"Docker Compose Template
Section titled “Docker Compose Template”services: tailscale: image: {{ tailscale_image }}:{{ tailscale_version }} container_name: tailscale hostname: {{ tailscale_hostname }} environment: - TS_STATE_DIR=/var/lib/tailscale{% if tailscale_auth_key is defined and tailscale_auth_key %} - TS_AUTHKEY={{ tailscale_auth_key }}{% endif %} - TS_EXTRA_ARGS={{ tailscale_extra_args }} - TS_USERSPACE={{ tailscale_userspace | string | lower }} volumes: - {{ tailscale_base_dir }}:/var/lib/tailscale - /dev/net/tun:/dev/net/tun cap_add: - NET_ADMIN - SYS_MODULE network_mode: host restart: unless-stoppedAuth Key Loading Logic
Section titled “Auth Key Loading Logic”- name: Check if Tailscale state exists ansible.builtin.stat: path: "{{ tailscale_base_dir }}/tailscaled.state" register: tailscale_state
- name: Load auth key from SOPS (fresh deploy only) ansible.builtin.set_fact: tailscale_auth_key: "{{ (lookup('community.sops.sops', tailscale_sops_file) | from_yaml).tailscale_auth_key }}" when: - not tailscale_state.stat.exists - tailscale_auth_key is not defined no_log: trueBoot Script
Section titled “Boot Script”#!/bin/bashset -euo pipefail
cd /home/pi/.firewalla/run/docker/tailscale || exit 1/usr/bin/docker compose up -dNumbered 0150 to start after Docker (0050) but before vault-unseal (0200) since Tailscale provides network connectivity.
Playbook Integration
Section titled “Playbook Integration”Add to router-playbook.yml:
- role: router-tailscale tags: - tailscalePosition early in the role list before services that may need Tailscale connectivity.
Security Considerations
Section titled “Security Considerations”- Auth key encrypted in git: SOPS with age encryption
- Key only used on fresh deploy: State persistence avoids re-auth
- Capabilities:
NET_ADMINandSYS_MODULErequired for kernel networking - Network mode: Host mode required for VPN functionality
Migration from Manual Setup
Section titled “Migration from Manual Setup”- Existing
/extdata/tailscale-datapreserved (no re-auth needed) - Old compose file replaced with Ansible-managed version
- Boot hook added (currently missing)
- Version pinned from
:latesttov1.92.5
Implementation Tasks
Section titled “Implementation Tasks”- Create role directory structure
- Write defaults/main.yml with configurable values
- Write templates for compose and boot script
- Write task files following vault-unseal pattern
- Add role to router-playbook.yml
- Create SOPS secrets file (if fresh deploy needed)
- Test with
--check --difffirst - Deploy and verify Tailscale connectivity
Verification
Section titled “Verification”After deployment:
# Check container is runningssh pi@192.168.20.1 "docker ps | grep tailscale"
# Check Tailscale statusssh pi@192.168.20.1 "docker exec tailscale tailscale status"
# Verify boot hook existsssh pi@192.168.20.1 "ls -la /home/pi/.firewalla/config/post_main.d/*tailscale*"Rollback
Section titled “Rollback”If issues occur:
# Stop the managed servicessh pi@192.168.20.1 "cd /home/pi/.firewalla/run/docker/tailscale && docker compose down"
# State is preserved in /extdata/tailscale-data# Can restart manually or restore previous compose file