Skip to content

Router Tailscale Role Design

Bead: selfhosted-cluster-gv9 Date: 2026-01-18

Create an Ansible role to manage Tailscale on the Firewalla router, following patterns from router-vault-unseal and alloy roles.

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

Constraint: Minimize router dependencies on Vault for core services.

Approach: SOPS-encrypted file with age encryption, matching vault-unseal pattern.

ansible/secrets/router-tailscale.sops.yaml
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.

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.j2
defaults/main.yml
tailscale_base_dir: "/extdata/tailscale-data"
tailscale_image: "ghcr.io/tailscale/tailscale"
tailscale_version: "v1.92.5"
tailscale_hostname: "router"
tailscale_advertise_exit_node: true
tailscale_advertise_routes:
- "192.168.0.0/16"
- "10.0.0.0/8"
tailscale_userspace: false
# Firewalla paths
tailscale_docker_compose_dir: "/home/pi/.firewalla/run/docker/tailscale"
tailscale_post_main_dir: "/home/pi/.firewalla/config/post_main.d"
# SOPS file path
tailscale_sops_file: "{{ playbook_dir }}/secrets/router-tailscale.sops.yaml"
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-stopped
- 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: true
0150-start-tailscale.sh
#!/bin/bash
set -euo pipefail
cd /home/pi/.firewalla/run/docker/tailscale || exit 1
/usr/bin/docker compose up -d

Numbered 0150 to start after Docker (0050) but before vault-unseal (0200) since Tailscale provides network connectivity.

Add to router-playbook.yml:

- role: router-tailscale
tags:
- tailscale

Position early in the role list before services that may need Tailscale connectivity.

  1. Auth key encrypted in git: SOPS with age encryption
  2. Key only used on fresh deploy: State persistence avoids re-auth
  3. Capabilities: NET_ADMIN and SYS_MODULE required for kernel networking
  4. Network mode: Host mode required for VPN functionality
  • Existing /extdata/tailscale-data preserved (no re-auth needed)
  • Old compose file replaced with Ansible-managed version
  • Boot hook added (currently missing)
  • Version pinned from :latest to v1.92.5
  1. Create role directory structure
  2. Write defaults/main.yml with configurable values
  3. Write templates for compose and boot script
  4. Write task files following vault-unseal pattern
  5. Add role to router-playbook.yml
  6. Create SOPS secrets file (if fresh deploy needed)
  7. Test with --check --diff first
  8. Deploy and verify Tailscale connectivity

After deployment:

Terminal window
# Check container is running
ssh pi@192.168.20.1 "docker ps | grep tailscale"
# Check Tailscale status
ssh pi@192.168.20.1 "docker exec tailscale tailscale status"
# Verify boot hook exists
ssh pi@192.168.20.1 "ls -la /home/pi/.firewalla/config/post_main.d/*tailscale*"

If issues occur:

Terminal window
# Stop the managed service
ssh 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