Tailscale homelab reference: overlay as second path and management plane
How Tailscale runs in this homelab: as a parallel overlay alongside the site-to-site IPsec/GRE tunnels (covered in Magic WAN interop), with its own jobs - out-of-band management, a third path when LAN and tunnel routes are both unavailable, an identity layer for SSH, and since 2026-08-21 a subnet router that reaches VLAN-only devices from any network. Covers the NixOS authkey pattern, the subnet-router cutover, the Unraid plugin, the multi-path SSH alias scheme, and every failure mode observed in production. Hostnames, addresses, and paths are anonymised; the patterns are exactly as built.
TL;DR: Tailscale earns its place for OOB access, per-device identity, and - since the 2026-08-21 cutover - a subnet router that puts every local VLAN one tunnel away from any network; not for bulk site-to-site traffic (IPsec/GRE wins there). The configurations to copy are the NixOS authKeyFile pattern (a rebuilt node rejoins unattended), SSH Host pattern lists (one identity block, three addresses), and the scoped subnet-router firewall rule (tailnet source in, local destinations only, never the WAN). The failures to avoid: placeholder authkeys, stale tailnet entries in a WSL-generated /etc/hosts, an overlay node that was never authenticated in the first place, and routes that are advertised but not forwarded.
What the tailnet is for
Section titled “What the tailnet is for”Four jobs, in order of how much they matter day to day:
- Out-of-band management. When the edge router, the IPsec/GRE tunnel, or local DNS is broken, the tailnet is the path that still works - it shares no fate with any of them. PiKVMs and the second-site Pi stay reachable even when the primary network does not.
- A third path for SSH. Every host alias carries a
10.0.xaddress (LAN or site-to-site), a10.68.xaddress (cross-tunnel), and the MagicDNS name. The tailnet path works from arbitrary networks - a hotel line, a phone hotspot - where neither RFC1918 path exists. - Identity for SSH. Tailscale SSH on the Unraid box replaces
authorized_keysdistribution with tailnet policy, and check mode adds browser re-authentication for high-risk connections.1 - Reaching devices that cannot run tailscaled. The router advertises every local VLAN as a subnet route, so the switch, BMCs, and other VLAN-only gear are reachable from any network a tailnet client sits on - enabled 2026-08-21 (see subnet routing).
It is not for bulk traffic between sites: the IPsec/GRE tunnels move more data at lower latency because they are routed, not relayed, and do not depend on fallback via DERP (Designated Encrypted Relay for Packets, Tailscale’s relay network) when NAT traversal fails.
| Decision | Payoff | Cost |
|---|---|---|
| Tailscale alongside IPsec/GRE, not instead of | Independent failure domain for OOB | Two overlays to reason about |
| Subnet routing on the router, every local VLAN | VLAN-only devices (switch, BMCs) reachable from any client network | Console approval after each re-advertisement; per-client route acceptance |
| Tailscale SSH on Unraid only | Kills key-distribution toil where it hurt most | Check-mode re-auth URLs interrupt automation |
| Authkey file on the NixOS appliance | Rebuilt node rejoins unattended | Key material on disk; 90-day max key lifetime2 |
Topology
Section titled “Topology”Until 2026-08-21 every node was box-level: tailnet devices reached tailnet devices, nothing more. The router now also advertises every local VLAN as subnet routes, so tailnet clients reach VLAN-only devices too (the switch, BMCs, anything without tailscaled) - the cutover story is in subnet routing. Its input firewall still treats tailscale0 as a management interface - SSH, the management UI port, and ping - and its forward chain carries exactly one rule for subnet-routed traffic, scoped to local destinations only.
Node classes
Section titled “Node classes”| Class | Examples | How it joins | Verified state |
|---|---|---|---|
| NixOS router | edge router | Interactive tailscale up once; node key persists under /var/lib/tailscale | BackendState: Running, home DERP relay #3 |
| NixOS appliance | IoT Pi | authKeyFile + tailscaled-autoconnect unit | Running; autoconnect unit inert after registration |
| Unraid | media/automation server | Plugin manager, interactive login once | Running, Tailscale 1.102.2 |
| Windows + WSL2 | dev box | Windows GUI client; WSL2 has no tailscaled | - |
| OOB | PiKVMs | Stock tailscaled on the PiKVM OS | - |
Two consequences of the split. First, the authkey-joined node can rebuild and rejoin without a browser; the interactively-joined nodes cannot, but their node keys survive reboots under /var/lib/tailscale. Second, anything on the Windows box resolves MagicDNS through the Windows client - WSL2 inherits that DNS but runs no daemon of its own, which produces two of the failure modes below.
Declarative tailscaled on NixOS
Section titled “Declarative tailscaled on NixOS”The two variants
Section titled “The two variants”The router gets the management-plane form - enable the service with the subnet-router flags, open the management ports on tailscale0 in the input chain, authenticate interactively once:
# router: management plane + subnet router. First auth is interactive:# `sudo tailscale up` + browser URL (one-time; node key persists# under /var/lib/tailscale). The advertise-routes flags take effect# on the next `tailscale up`, not on rebuild alone.services.tailscale = { enable = true; useRoutingFeatures = "server"; extraUpFlags = [ "--advertise-routes=<every local VLAN /24, comma-separated>" ];};
# nftables input: management plane from the tailnet# iifname "tailscale0" tcp dport { 22, <mgmt-ui-port> } accept# iifname "tailscale0" icmp type echo-request acceptThe IoT Pi gets the unattended form. The nixpkgs services.tailscale module generates a tailscaled-autoconnect systemd unit when authKeyFile is set; the unit polls tailscale status --json and, on NeedsLogin, runs tailscale up --auth-key "$(cat <keyfile>)" plus any extraUpFlags, then exits once the state reaches Running:3
# IoT Pi: Tailscale is the second way in when the LAN path breaks.# The auth key is placed by hand, out-of-repo like the other secrets;# tailscaled consumes it on first start and ignores it once the node# is registered:# printf %s '<key from the admin console>' > /etc/tailscale-authkey# chmod 600 /etc/tailscale-authkeyservices.tailscale = { enable = true; authKeyFile = "/etc/tailscale-authkey";};Verified live after provisioning: BackendState: Running, and tailscaled-autoconnect is inactive - the unit is a one-shot that did its job on first boot and now sits inert. The key file on disk is ignored from then on; the node’s identity lives in /var/lib/tailscale.
Gotchas, all observed
Section titled “Gotchas, all observed”Authkeys expire after 1-90 days (90 default), so the key on disk is a consumable: if the node ever needs to re-register after the key lapses, generate a new one and drop it in place. An already-registered node stays authorised until its node key expires.2 Note that re-registration also means a new tailnet IP - when the Pi was re-registered, its old 100.x address was gone and anything pinned to it (including a stale /etc/hosts entry, below) dialed a dead node.
Subnet routing
Section titled “Subnet routing”Box-level reachability ended 2026-08-21, mid-servarr-cutover: the dev box gave its wired switch port to the new server and moved to the wireless VLAN, which has no route to the admin VLAN by design. The switch, the new server’s BMC, every VLAN-only device became unreachable. The router now advertises every local VLAN as a subnet route, and the tailnet is the always-on bypass for any network a client sits on. The config is the router variant above; four things make the routes actually carry traffic:
-
Approval. Advertised routes are inert until approved in the admin console (Machines -> the router -> Edit route settings -> Save). The tailnet policy is not in git, so this is a manual step after every re-advertisement;
autoApprovers.routesin the policy would remove the click. -
Forwarding. The nft forward chain (policy drop) needs its own rule - the input rules that define the management plane do nothing for transit traffic:
# tailnet -> local VLANs only; no WAN egress via the tailnet# (this box is not an exit node).iifname "tailscale0" ip saddr 100.64.0.0/10 ip daddr 10.0.0.0/8 acceptSource-scoped to the tailnet CGNAT range, destination-scoped to local space: a tailnet client can reach VLAN devices but cannot egress to the WAN through the router. “Use as exit node” stays off in the console.
-
Re-approval on every change to the advertised set. Adding or renumbering a VLAN (2026-09-04: the IoT segment moved to a new /24 and the router’s
--advertise-routeslist changed with it) puts the router back at step 1: the new route sits unapproved until the console click, and clients keep the old one until then. -
Client acceptance. Off by default, per client: the Windows tray toggle (“Use Tailscale subnets”) or
tailscale up --accept-routes. WSL2 has no tailscaled - it inherits accepted routes through the Windows client’s NAT.
Each row below cost time during the enablement, each with a signal that made the step look done when it was not:
| Observation | What it proves | Where to look when it fails |
|---|---|---|
tailscale ping <vlan-host> answers | Control plane only - the route is advertised, approved, and accepted | Test TCP; ping passing while TCP fails points at the forward chain |
| TCP to a VLAN host times out | The forward chain dropped it | nft list chain inet filter forward - is the tailscale0 rule there? |
| Console machine page lists the route as available | Nothing - the Edit dialog’s checkboxes do nothing until Save | Re-open Edit route settings, tick, Save; the machine page’s Approved list is the truth |
Get-NetRoute shows the VLAN route on Windows | Nothing - the OS installs the route before the tunnel accepts it | tailscale status --json AllowedIPs on the router peer: VLAN ranges present means accepted, a lone /32 means not |
tailscale up on the router prints a UDP GRO warning for its NIC - a throughput note, not an error; the ethtool fix is optional.
Tailscale SSH on Unraid
Section titled “Tailscale SSH on Unraid”Unraid gets Tailscale from the plugin manager (the tailscale plugin plus unraid-tailscale-utils), not from a container. Enabling Tailscale SSH is one command on the host:
tailscale set --sshThat claims port 22 on the tailnet IP only - the Unraid SSH daemon, sshd_config, and authorized_keys are untouched, and SSH over the LAN IP keeps working exactly as before.1
The default tailnet policy puts SSH to your own devices in check mode: the first connection of the day returns a login.tailscale.com/a/... URL and waits for browser re-authentication, then stays authorised for the 12-hour check period.1 Two operational notes:
- Deploy scripts that SSH to the box (mine takes the tailscale IP as an argument) fail on the first run of the day until someone opens the re-auth URL. Run one manual
sshfirst, then let automation ride the 12-hour window. - Check rules win over accept rules when both match, so adding a permissive
acceptrule does not bypass check mode - you must scope theacceptrule more precisely than thecheckrule.1
Multi-path SSH aliases
Section titled “Multi-path SSH aliases”The SSH config gives each host one block, many addresses. The Host line is a pattern list covering every way the host is addressed; the block below it carries the user and identity file. You pick the path by what you type:
Host unraid 10.0.x.2 10.68.x.2 unraid.<tailnet>.ts.net User root IdentityFile ~/.ssh/id_unraid IdentitiesOnly yesssh unraid resolves through local DNS (LAN) or MagicDNS; ssh 10.0.x.2 forces the primary path; ssh 10.68.x.2 forces the cross-tunnel backup. Same identity either way, no duplicate blocks to drift.
The hard rules that keep this survivable:
IdentitiesOnly yeson every block. Without it, ssh-agent offers every key in turn and locks accounts (Unraidrootafter a handful of offers) before the right key is tried.- MagicDNS names are patterns, never
HostName. PinHostNameto the LAN address. The tailnet name stays an alias you can type, not a resolution the config depends on. - Tailnet names do not override local DNS. The router’s own zones win for short names; the tailnet name is always the FQDN form.
Access control notes
Section titled “Access control notes”The idioms in use, all from the policy-file syntax reference:5
autogroup:internetas adstis what permits exit-node use. A device with no grant toautogroup:internetcannot use any exit node, and ACLs cannot restrict which exit node - that needs grants withvia.autoApprovers.routesremoves the manual approval click now that subnet routing is live: the tailnet policy is not in git, so every re-advertisement currently needs a console approval. Auto-approval only applies when the route is first advertised - editing the policy file does not retroactively approve a pending route; remove and re-advertise it.- Tags beat users for servers. A tagged node authenticates with an authkey, gets key expiry disabled by default,2 and its access is governed by the tag rather than by whoever happened to log it in.
tests. Adenytest on a sensitive subnet fails the policy-file save if a future edit accidentally opens it.
Failure modes observed
Section titled “Failure modes observed”| Failure | Observation | Root cause | Fix | Evidence |
|---|---|---|---|---|
| Placeholder authkey | nixos-rebuild switch fails; NeedsLogin | Verbatim paste of the README line wrote literal placeholder text into two of three secret files | printf %s the real key; verify byte count | install README |
| Newline in key file | tailscale up rejects the key | echo appends \n | printf %s | install README |
| No second path during LAN incident | NIC naming trap broke LAN; tailnet unreachable too | Tailscale on the box had never been authenticated | authkey provisioning so a rebuild can never leave it un-authenticated | config comment |
| Alias dialed a dead node | ssh iot-pi hung while the LAN IP worked | WSL-generated /etc/hosts held a stale tailnet entry for the previous OS install | Pin HostName to the LAN address | ssh config comment |
| Dead tailnet IP after re-registration | Old 100.x address gone | Re-registering a node allocates a new tailnet IP | Never pin configs to tailnet IPs; use MagicDNS names | ssh config comment |
ping shows tx 1404 rx 0 to dev box | Tailnet healthy, all other peers fine | Windows-side Tailscale broken, not the tailnet | Check tailscale status --json BackendState, not just ping | session, 2026-07 |
| WSL2: ping fails, SSH works | ping <alias> no replies, ssh <alias> fine | Windows does not answer tailnet ICMP on the dev box | Test with the protocol you actually use | session, 2026-07 |
| Monitoring pipeline died mid-run | Postgres at 100.x:5433 unreachable; 8300-row run lost | tailscaled restarted on the DB host; the tailnet had silently become a production dependency | Treat tailnet IPs in connection strings as dependencies to monitor, not conveniences | session, 2026-08 |
| Subnet routes advertised but TCP dead | tailscale ping to a VLAN host answered, TCP to the same host timed out | nft forward chain (policy drop) had no tailscale0 rule - the control plane resolved the route, the forwarding plane dropped the packets | One scoped forward rule: tailnet source, local destinations only | session, 2026-08-21 |
| Client ignored advertised routes | VLAN ranges absent from the router peer’s AllowedIPs (its own /32 only); Windows Get-NetRoute showed the routes anyway | Per-client route acceptance off (Windows tray setting) | Enable “Use Tailscale subnets” / tailscale up --accept-routes; verify AllowedIPs, not the OS route table | session, 2026-08-21 |
Verification checklist
Section titled “Verification checklist”After touching anything Tailscale-related on a node:
| Check | Command | Expected |
|---|---|---|
| Daemon state | tailscale status --json | jq -r .BackendState | Running |
| Authkey file is real | wc -c /etc/tailscale-authkey | Key length, no trailing newline; not 15-ish placeholder bytes |
| Autoconnect consumed | systemctl is-active tailscaled-autoconnect | inactive after first registration (one-shot, done) |
| Router management plane | ssh router from a tailnet device | Connects over the tailnet IP |
| Tailscale SSH | ssh unraid from a tailnet device | Connects, or returns a check-mode URL (re-auth, then retry) |
| Path selection | ssh 10.0.x.2 vs ssh 10.68.x.2 | Same identity, both land on the same host |
| MagicDNS | getent hosts <host>.<tailnet>.ts.net | Current 100.x address (compare against the admin console after any re-registration) |
| DERP vs direct | tailscale netcheck; tailscale ping <peer> | Nearest relay region; direct after a few tries |
| Subnet routes advertised | tailscale debug prefs | grep -A8 AdvertiseRoutes (on the router) | Every local VLAN /24 |
| Route acceptance (client) | tailscale status --json | jq '.Peer[] | {h: .HostName, a: .AllowedIPs}' | Router peer lists the VLAN ranges, not just its own /32 |
| Subnet forwarding plane | TCP to a VLAN-only host, e.g. Test-NetConnection <ip> -Port 22 | Connects - tailscale ping alone proves only the control plane |
References
Section titled “References”-
Tailscale, “Tailscale SSH.” https://tailscale.com/kb/1193/tailscale-ssh ↩ ↩2 ↩3 ↩4
-
Tailscale, “Auth keys.” https://tailscale.com/kb/1085/auth-keys ↩ ↩2 ↩3
-
nixpkgs, “services.tailscale module (tailscaled-autoconnect).” https://github.com/NixOS/nixpkgs/blob/nixos-unstable/nixos/modules/services/networking/tailscale.nix ↩
-
Tailscale, “tailscale up command.” https://tailscale.com/docs/reference/tailscale-cli/up ↩
-
Tailscale, “Syntax reference for the tailnet policy file.” https://tailscale.com/docs/reference/syntax/policy-file ↩
-
Tailscale, “Subnet routers.” https://tailscale.com/kb/1019/subnets ↩