Networking Vpn
Networking: Tailscale & Headscale
Secure, zero-config mesh VPN (SD-WAN) and its self-hosted coordination server.
Overview
This repository provides first-class support for private networking via the Tailscale protocol. We support both the official Tailscale managed service and a fully self-hosted Headscale implementation.
Key Concepts
- MagicDNS: Automatically assigns a stable, human-readable DNS name (e.g.,
my-server.hs.alienzj.org) to every device in your private network. You can access services via these names instead of IP addresses. - DERP (Detoured Encrypted Routing Protocol): A relay system used as a fallback when two devices cannot establish a direct peer-to-peer (P2P) connection due to strict firewalls or NAT.
- Node: Any device (server, laptop, phone) running the Tailscale client and connected to your mesh.
Machine Identity: Tags vs User Login
Tailscale supports two mutually exclusive identity models per device:
| Identity | Key Expiry | Use Case |
|---|---|---|
User login (tailscale up) | 180 days (re-auth required) | Laptops, phones, personal workstations |
| Tagged (auth key + tags) | Disabled (never expires) | Servers, NAS, subnet routers, always-on infra |
Tags are exclusively for non-human devices. Applying a tag removes the user identity from the device — a tagged device cannot SSH into user-authenticated machines. Tags are not designed for end-user devices like MacBooks or phones.
Warning: Adding a tag to a previously user-authenticated device removes the user’s identity. If that user is later removed from the tailnet, any previously tagged machine they owned remains on the network.
Tailscale (Official)
Enabled via the ts0 network profile.
- Profile:
modules/profiles/network/ts0.nix - Usage: Add
"ts0"to themodules.profiles.networkslist in your host configuration.
User Authentication (Laptops / Phones)
For interactive devices where you want per-user identity:
sudo tailscale up
Complete the web authentication flow. The node key expires after 180 days and must be re-authenticated.
Tagged Authentication (Servers / Infra)
For always-on infrastructure, use a pre-approved auth key with tags. Tagged devices have key expiry disabled by default — they stay connected permanently without intervention.
1. Generate Auth Key
In the Tailscale admin console (https://login.tailscale.com/admin/settings/keys):
- Reusable: Enabled
- Ephemeral: Disabled
- Tags:
tag:nixos(or a more specific tag liketag:prod-server)
2. Encrypt with agenix (per host)
The module expects a secret named <hostName>.tailscaleAuthkeyFile. Encrypt the auth key for each host:
echo -n 'tskey-auth-xxxxx' | agenix -e id3-eniac.tailscaleAuthkeyFile.age
echo -n 'tskey-auth-xxxxx' | agenix -e vps-pacman.tailscaleAuthkeyFile.age
Place the encrypted .age files in your secrets directory. You can use the same auth key value for all hosts — the per-host secret files just let agenix encrypt to each host’s SSH key independently.
3. Declare the Secret (per host)
In hosts/<name>/modules/secrets.nix:
age.secrets."${config.networking.hostName}.tailscaleAuthkeyFile" = {
mode = "0400";
owner = "root";
};
No host-level config wiring is needed — ts0.nix automatically resolves the secret path from config.networking.hostName.
The module maps this to nixpkgs’ services.tailscale.authKeyFile, which feeds the key to tailscaled at startup. The node auto-authenticates with the tag, and its key never expires.
Auth Key Strategy for Multiple Hosts
One reusable auth key with tag:nixos works for all NixOS machines. Each device gets the same key; Tailscale identifies them by hostname + per-device node keys. The auth key only authorizes join permission — it does not become the device’s identity.
| Scope | Tag | Devices |
|---|---|---|
| One reusable key | tag:nixos | All NixOS servers |
| Separate key per non-NixOS device | tag:nas, tag:router | QNAP NAS, OpenWRT, etc. |
| User login | — | Laptops, phones |
Tag Naming Conventions
Tags follow the tag:<purpose> pattern. Use descriptive names that convey role:
| Tag | Purpose |
|---|---|
tag:nixos | General NixOS infrastructure |
tag:nas | NAS / storage appliances |
tag:router | Subnet routers |
tag:builder | CI/CD build nodes |
For composite roles, combine into a single tag (Tailscale does not support multi-tag intersection in ACL rules):
# Good — single composite tag
tag:prod-database
# Avoid — cannot write ACLs targeting "both tag:prod AND tag:database"
tag:prod + tag:database
Headscale (Self-Hosted)
For full sovereignty, we provide a Headscale implementation that replaces the Tailscale coordination server.
Server Setup
The server is managed via modules/services/net/headscale.nix. It is high-performance, using:
- Database: PostgreSQL (managed via our declarative
ensuressystem). - Reverse Proxy: Nginx with QUIC/HTTP3 and kTLS enabled.
- Network: Uses the standard
100.64.0.0/10(IPv4) andfd7a:115c:a1e0::/48(IPv6) ranges for compatibility with official Tailscale clients.
Client Setup (hs0)
To connect a node to your own Headscale instance:
- Add
"hs0"to themodules.profiles.networkslist. - The
hs0profile (modules/profiles/network/hs0.nix) pre-configures the--login-serverflag. - Authenticate by running:
sudo tailscale up --login-server https://hs.yourdomain.com
Custom DERP & DNS
By default, Headscale uses official Tailscale relay nodes by fetching the global DERP map. You can also declaratively define private DNS records and point to a custom DERP map:
modules.services.net.headscale = {
enable = true;
derpMapUrl = "https://hs.${baseDomain}/derpmap/default";
dnsRecords = [
{ name = "git.internal"; type = "A"; value = "100.64.0.5"; }
];
};
Hosting a Custom DERP Map
If you host your own relay nodes (using the derper tool), you can serve the map via Nginx:
# Example Nginx config to serve a custom DERP map
services.nginx.virtualHosts."hs.${baseDomain}".locations."/derpmap/default" = {
extraConfig = ''
root /etc/derper;
rewrite ^/derpmap/default$ /servers.json break;
default_type application/json;
'';
};
# Define the nodes in environment.etc
environment.etc."derper/servers.json".text = builtins.toJSON {
Regions = {
"1" = {
RegionID = 1;
RegionCode = "hel";
RegionName = "Helsinki";
Nodes = [{
Name = "1a";
RegionID = 1;
HostName = "your-relay-host.com";
IPv4 = "1.2.3.4";
CanPort80 = true;
}];
};
};
};
Key Expiry Reference
| Scenario | Key Expiry | Mitigation |
|---|---|---|
User login (tailscale up) | 180 days | Re-auth with tailscale up |
| Tagged via auth key | Disabled | None needed — permanent |
| Tagged before 2022-03-10 | Enabled until re-auth | Re-authenticate once |
| Tags changed via admin console | Unchanged | Re-authenticate to disable |
Key expiry can be manually toggled per device from the Machines page in the admin console.
Diagnosis
Connectivity Tests
# List all peers and their status
tailscale status
# Filter for a specific host
tailscale status | grep nas-nasa
# Check JSON (includes KeyExpiry per peer)
tailscale status --json | jq '.Peer[] | {HostName, KeyExpiry, Online}'
# Basic reachability
tailscale ping nas-nasa # hostname
tailscale ping 100.64.243.70 # IP
# Check routing path to a Tailscale IP
ip route get 100.64.243.70 # should show dev tailscale0 table 52
# Verify Tailscale routing table exists
ip route show table 52
Service Port Tests (Direct)
Test that services on a Tailscale host are reachable without the proxy (uses system route via tailscale0):
# Test individual ports
curl -sk --max-time 5 -o /dev/null -w "HTTP %{http_code}\n" http://100.64.243.70:8096 # Jellyfin
curl -sk --max-time 5 -o /dev/null -w "HTTP %{http_code}\n" http://100.64.243.70:6363 # qBittorrent
curl -sk --max-time 5 -o /dev/null -w "HTTP %{http_code}\n" https://100.64.243.70:5001 # QNAP Admin
# Or scan all known service ports at once
for port in 5001 8096 6363 22 445; do
curl -sk --max-time 3 -o /dev/null -w "port $port: HTTP %{http_code}\n" \
"http://100.64.243.70:$port" 2>&1
done
Expected results:
:5001→ HTTP 400 (plain HTTP), HTTPS 200:8096→ HTTP 302 (Jellyfin redirect to login):6363→ HTTP 200 (qBittorrent web UI)
Service Port Tests (Through Proxy)
Test that sing-box’s SOCKS5 proxy can also reach Tailscale hosts (verifies the route rules in the sing-box config):
# These MUST work if sing-box has 100.64.0.0/10 → direct route rule
curl -sk --max-time 5 --socks5 127.0.0.1:2080 -o /dev/null -w "HTTP %{http_code}\n" http://100.64.243.70:8096
curl -sk --max-time 5 --socks5 127.0.0.1:2080 -o /dev/null -w "HTTP %{http_code}\n" https://100.64.243.70:5001
If these timeout but direct tests pass:
- sing-box route rules are missing
100.64.0.0/10 → direct - Fix: add the rule to
route.rulesin the sing-box config (seedocs/networking-proxy.md)
DNS Resolution
# Check Tailscale DNS status on this host
tailscale dns status
# Resolve a Tailscale hostname via MagicDNS
dig +short nas-nasa @100.100.100.100
# Check that 100.100.100.100 is reachable (local virtual address)
ping -c1 -W1 100.100.100.100
After Sleep / Wake
# Verify all networking services survived
systemctl is-active tailscaled systemd-networkd sing-box
# Check Tailscale reconnected
tailscale ping -c 2 nas-nasa
# Check TUN interface is up
ip link show tun0 | grep -q UP && echo "tun0 OK" || echo "tun0 DOWN"
# Check for sleep-related errors in logs
journalctl --since "10 minutes ago" | grep -i 'sleep\|suspend\|resume\|reconnect\|Peer.*expired'
After wake, systemd-networkd handles interface bring-up automatically. tailscaled reconnects on its own. If sing-box is stopped after wake, the sing-box-resume companion service fix (hey sync) has not been deployed yet — sudo systemctl start sing-box as a workaround.
Common Issues
“peer’s node key has expired” — The remote device’s key expired. Fix: either tag the device (permanent) or re-run tailscale up on it (180-day renewal).
Can ping but can’t reach port — The service on the target device is not listening on the Tailscale interface. Check ss -tlnp on the target.
Traffic goes through WAN instead of Tailscale — Check routing tables. sing-box TUN mode can intercept Tailscale IPs if the route isn’t in table 52. Verify with ip route get <ip>.
Direct works but proxy doesn’t — sing-box route rules don’t include 100.64.0.0/10 → direct. The TUN excludes CGNAT (so system traffic bypasses sing-box and works), but the mixed inbound routes through sing-box’s own rules. Add the missing route rule and reload.
Tailscale IP changed after reinstall — Update all hardcoded IP references in the repo: startpage (packages/startpage/index.html), sing-box tunnels, docs. Use grep -rn '<old-ip>' --include='*.html' --include='*.nix' --include='*.md' to find them all.
Why a Node Uses DERP Relay
Tailscale tries to establish a direct peer-to-peer WireGuard connection between nodes. If direct NAT traversal fails, it falls back to DERP (Detoured Encrypted Routing Protocol) — a relay server that forwards encrypted traffic between the two nodes.
How to check:
tailscale status
# "direct" → peer-to-peer WireGuard (fast, low latency)
# "relay X" → traffic forwarded through DERP server X (slower, higher latency)
# "offline" → node is not reachable at all
Common causes of relay fallback:
| Cause | Diagnosis | Fix |
|---|---|---|
| Node is powered off / sleeping | tailscale status shows offline | Power on the node. A relay status + offline means the node was last seen through relay before going offline — not an active relay. |
| Restrictive firewall blocking UDP | tailscale ping <node> fails or times out | Allow outbound UDP on port 41641. Check iptables -L and sing-box route rules. |
| Double NAT / CGNAT | Both nodes behind NAT with no UPnP | ISP CGNAT (100.64.x.x WAN IP + router NAT = double NAT). Tailscale can still punch through most double NATs, but degraded CGNAT implementations may force relay. |
| sing-box TUN intercepting Tailscale UDP | ip route get 100.64.243.70 shows tun0 instead of tailscale0 | Ensure 100.64.0.0/10 is in sing-box’s route_exclude_address and has a direct route rule. See sing-box.md. |
| No common DERP region | Nodes in different geographic regions with no overlapping DERP | Add a DERP server in a mutually reachable region, or ensure both nodes can reach default Tailscale DERPs. |
ros-rolling specific note: As of June 2026, ros-rolling appeared as
active; relay "sfo" while powered off (ping 192.168.31.91: Destination Host Unreachable). When next powered on, check:
tailscale status— confirm it showsdirectonce online- sing-box config — verify
route_exclude_addressincludes100.64.0.0/10 - Firewall — ensure UDP port 41641 is not blocked outbound
- LAN path — it should be on the same
192.168.31.0/24switch as other Study A nodes
Jellyfin/LAN Services: Use LAN IP, Not Tailscale IP
When both the client and server are on the same LAN (192.168.31.0/24),
always configure the client to use the LAN IP for local streaming. Using the
Tailscale IP (100.64.243.70) for LAN services triggers application-level
throttles because the 100.64.0.0/10 range is not classified as private.
The problem in detail:
Python’s ipaddress module (used by many streaming/media apps) classifies RFC
6598 addresses as “global/remote”:
>>> import ipaddress
>>> ipaddress.IPv4Address("100.64.243.70").is_private
False
>>> ipaddress.IPv4Address("100.64.243.70").is_global
True
This means any application that determines “local vs remote” via
is_private/is_global will throttle connections to Tailscale IPs.
Real case — jellyfin-mpv-shim (June 2026):
| Setting | Value | When applied |
|---|---|---|
local_kbps | 2,147,483 (~2.1 Gbps) | Server IP is RFC 1918 private (e.g. 192.168.31.12) |
remote_kbps | 10,000 (~10 Mbps) | Server IP is “global” (e.g. 100.64.243.70) |
The cred.json at ~/.config/jellyfin-mpv-shim/cred.json had stored the
Tailscale IP:
{"address": "http://100.64.243.70:8096", ...}
Fix — change to the LAN IP:
{"address": "http://192.168.31.12:8096", ...}
Then restart jellyfin-mpv-shim. Verify with:
# Should show 192.168.31.x → 192.168.31.12, NOT 100.x → 100.x
ss -tnp | grep 8096
Other affected applications:
- Syncthing / Resilio Sync: May force relay-only mode for “non-LAN” peers
- Any Python app using
ipaddress.is_private: Thinks your local NAS is remote - Media servers (Plex, Emby): May transcode or throttle streams to “remote” clients
Rule: For LAN-local services, always use the LAN IP (192.168.x.x).
Reserve Tailscale IPs for when you’re genuinely outside the home network and the
LAN is unreachable.
WireGuard (wg0)
Site-to-site VPN for direct host-to-host connectivity without a coordination server. Complementary to Tailscale — use Tailscale for mesh/DNS, WireGuard for fixed tunnels between known endpoints.
- Profile:
modules/profiles/network/wg0.nix - Usage: Add
"wg0/server"or"wg0/client"tomodules.profiles.networks - Subnet:
10.100.0.0/24(IPv4),fd2e:a01c:d041::/64(IPv6)
Key Generation
Each host needs a WireGuard private key, encrypted via agenix:
# 1. Generate private key
wg genkey
# 2. Encrypt with agenix (replace <host> with hostname)
echo -n '<private-key>' | agenix -e <host>.wg0PrivateKey.age
# 3. Place the .age file in your secrets directory
# 4. Declare the secret in hosts/<host>/modules/secrets.nix:
# age.secrets."${config.networking.hostName}.wg0PrivateKey" = {
# mode = "0400";
# owner = "systemd-network";
# };
The module auto-declares the secret with correct permissions — only the owner field may need adjustment per host. The key file must be readable by systemd-network (not root) because systemd-networkd manages the WireGuard netdev.
To get a peer’s public key (for adding to the server peer list):
echo '<private-key>' | wg pubkey
Server
Add "wg0/server" to the host’s modules.profiles.networks. The server:
- Listens on UDP 51820
- Assigns itself
10.100.0.1/24 - Masquerades traffic between peers (IPMasquerade = “both”)
- Peers are defined by public key + static IP (
.3,.4,.5, etc.)
Adding a new peer requires:
- Generate keypair on the new client
- Add the client’s public key to the server’s peer list in
wg0.nix - Add the server’s public key + endpoint to the client config
Client
Add "wg0/client" to the host’s modules.profiles.networks. The client:
- Connects to the server endpoint (
202.182.112.226:51820) - Routes
10.100.0.0/24andfd2e:a01c:d041::/64through the tunnel - Does not use DHCP or DNS from the tunnel
Verification
# Show interface status and peers
wg show wg0
# Test connectivity to a peer
ping 10.100.0.3
# Check routing
ip route get 10.100.0.3
Server vs Client
A host must be either server or client — not both. The server peer list is manually maintained; the client only needs the server’s public key and endpoint.
Advanced Routing
Our modules are optimized for “Relay Architecture”:
- VPS Ingress: A public VPS (like
vps-pacman) acts as a gateway. - Internal Forwarding: Nginx on the VPS proxies traffic over the Tailscale/Headscale IP to a backend homelab node.
- Real-IP Preservation: Every backend service automatically restores the original client IP from the
X-Forwarded-Forheader provided by the VPS, ensuring accurate logging and security (Fail2Ban).
QNAP NAS Setup
QNAP devices cannot run NixOS, so Tailscale must be installed manually via QPKG and configured with CLI commands. This section covers setup for nas-nasa (our QNAP TS-453D) and serves as a general reference for any QNAP NAS.
Prerequisites
- Hardware: x86_64 or arm64 QNAP device
- QTS version: Any recent QTS/QuTS release
- Tailscale account: Free for personal use
- No firewall ports needed: Tailscale establishes outbound-only connections
Installation
Method 1: App Center (simplest)
- Open App Center → Communications
- Find Tailscale → Install
- After install, click the Tailscale icon → Open
- Log in to your Tailscale network → Connect
The App Center version may lag behind upstream releases.
Method 2: Manual QPKG (latest version, recommended)
Download the latest stable QPKG from pkgs.tailscale.com/stable/#qpkgs:
| Architecture | QNAP Model Example | File |
|---|---|---|
x86_64 | TS-453D, TS-873A, TVS-872X | Tailscale_{ver}-1_x86_64.qpkg |
arm_64 | TS-133, TS-233 | Tailscale_{ver}-1_arm_64.qpkg |
arm-x41 | TS-431K, TS-231K | Tailscale_{ver}-1_arm-x41.qpkg |
# 1. Check latest version at pkgs.tailscale.com/stable/#qpkgs
VER="1.98.2"
ARCH="x86_64" # or arm_64, arm-x41, etc.
# 2. Download QPKG + checksum
curl -LO "https://pkgs.tailscale.com/stable/Tailscale_${VER}-1_${ARCH}.qpkg"
curl -LO "https://pkgs.tailscale.com/stable/Tailscale_${VER}-1_${ARCH}.qpkg.sha256"
# 3. Verify checksum
sha256sum -c "Tailscale_${VER}-1_${ARCH}.qpkg.sha256"
# 4. In QNAP App Center: click "Install Manually" (top right) → Browse → select .qpkg → Install
After installation, tailscaled starts automatically. A Tailscale app appears in My Apps.
CLI Access
The tailscale binary is inside the mounted QPKG directory. Access the NAS via SSH:
# Find the QPKG mount path
echo $(getcfg SHARE_DEF defVolMP -f /etc/config/def_share.info)/.qpkg/Tailscale/
# Run commands with full path
$(getcfg SHARE_DEF defVolMP -f /etc/config/def_share.info)/.qpkg/Tailscale/tailscale status
# Or add to PATH for the session
export PATH=$PATH:$(getcfg SHARE_DEF defVolMP -f /etc/config/def_share.info)/.qpkg/Tailscale/
tailscale status
Authentication: Tagged (Permanent)
For always-on NAS devices, use a pre-approved auth key with a tag so the key never expires:
-
Generate an auth key in the Tailscale admin console:
- Reusable: Enabled
- Ephemeral: Disabled
- Tags:
tag:nas
-
Authenticate via SSH on the QNAP:
export PATH=$PATH:$(getcfg SHARE_DEF defVolMP -f /etc/config/def_share.info)/.qpkg/Tailscale/ tailscale up --authkey tskey-auth-xxxxx --hostname nas-nasa -
Verify: The device appears in the admin console as
nas-nasawith tagtag:nasand key expiry Disabled.
Once tagged, the NAS stays permanently connected — no 180-day re-auth.
Features
| Feature | How |
|---|---|
| Taildrop | Files sent to the NAS appear in the Tailnet shared folder (auto-created) |
| Subnet router | tailscale up --advertise-routes=192.168.31.0/24 to expose LAN |
| Exit node | tailscale up --advertise-exit-node then approve in admin console |
| SSH over Tailscale | Enabled by default — SSH to nas-nasa or its Tailscale IP from any node |
| HTTPS | Tailscale Funnel or reverse-proxy through VPS (preferred) |
Subnet Router
To access the NAS’s LAN subnet from other Tailscale nodes:
tailscale up --advertise-routes=192.168.31.0/24
Then approve the routes in the Tailscale admin console → Machines → nas-nasa → Edit route settings. Other nodes on the tailnet can now reach 192.168.31.x devices.
Updating
Method 1: Auto-update (recommended)
Enable automatic updates so Tailscale keeps itself current without manual intervention:
export PATH=$PATH:$(getcfg SHARE_DEF defVolMP -f /etc/config/def_share.info)/.qpkg/Tailscale/
tailscale set --auto-update
Tailscale will automatically download and apply updates in the background. Verify with:
tailscale set --auto-update # check current setting
Method 2: Manual QPKG
# Check current version
tailscale version
# Download new QPKG from pkgs.tailscale.com/stable/#qpkgs
# Then in App Center: Install Manually → select new .qpkg → Install
# tailscaled restarts automatically; settings persist across upgrades
Third-party App Repositories: Do NOT add any third-party QNAP software sources (App Repository) for Tailscale. Tailscale is not distributed through community repos — only install from the official QNAP App Center or directly from pkgs.tailscale.com. Adding untrusted repos for Tailscale is a security risk with no benefit.
nas-nasa Specifics
| Detail | Value |
|---|---|
| Device | QNAP TS-453D |
| Architecture | x86_64 |
| LAN IP | 192.168.31.12 |
| Tailscale IP | 100.64.243.70 |
| Auth | Tagged (tag:nas), key expiry disabled |
| Subnet routes | 192.168.31.0/24 (advertised to tailnet) |
| Services | QNAP Admin (5001), Jellyfin (8096), qBittorrent (6363), SMB (445) |
Repository references:
- SMB mounts:
hosts/id3-eniac/modules/hardware.nix→//192.168.31.12/... - Startpage:
packages/startpage/index.html→ QNAP/Jellyfin/qBit links via Tailscale IP - sing-box SSH tunnel:
modules/services/net/sing-box.nix→ forwards remote 25001→192.168.31.12:5001
Troubleshooting:
- Can’t reach services after reinstall: Tailscale IP changes on reinstall. Update all references in the repo (startpage, docs, sing-box tunnels).
- SMB mounts broken: LAN IP (
192.168.31.12) stays the same — check credentials inid3-eniac.nas-nasa.smb-secrets. - App Center version stuck: Use manual QPKG install from
pkgs.tailscale.com/stable/.
Jellyfin on QNAP
Jellyfin runs as a QPKG on nas-nasa. Unlike Tailscale, Jellyfin can be safely updated via a trusted third-party App Repository.
Installation
- Download the QPKG from the official Jellyfin releases or the QNAP Club repository.
- In App Center → Install Manually → select the
.qpkg→ Install. - After install, open Jellyfin from My Apps → complete the web setup wizard.
Online Updates via Third-Party App Repository
The community-maintained repo by pdulvp enables in-place updates directly from App Center:
- Open App Center → click the gear icon ⚙ (Settings) in the top right.
- Switch to the App Repository tab → click Add.
- Fill in:
- Name:
pdulvp-jellyfin(or any descriptive name) - URL:
https://pdulvp.github.io/qnap-store/repos.xml
- Name:
- Click Add → close settings.
- Refresh App Center. If a new Jellyfin version is available, an Update button appears below the Jellyfin icon. Click it to update in-place — all settings and libraries are preserved.
Note: This third-party repo is specific to Jellyfin and maintained by the QNAP community. It is not applicable to Tailscale — do not add unknown repos searching for Tailscale packages.
Manual Update
If the repo doesn’t detect an update, download the latest QPKG from repo.jellyfin.org and install manually via App Center → Install Manually. Settings persist across manual reinstalls.