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:

IdentityKey ExpiryUse 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 the modules.profiles.networks list 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 like tag: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.

ScopeTagDevices
One reusable keytag:nixosAll NixOS servers
Separate key per non-NixOS devicetag:nas, tag:routerQNAP NAS, OpenWRT, etc.
User loginLaptops, phones

Tag Naming Conventions

Tags follow the tag:<purpose> pattern. Use descriptive names that convey role:

TagPurpose
tag:nixosGeneral NixOS infrastructure
tag:nasNAS / storage appliances
tag:routerSubnet routers
tag:builderCI/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 ensures system).
  • Reverse Proxy: Nginx with QUIC/HTTP3 and kTLS enabled.
  • Network: Uses the standard 100.64.0.0/10 (IPv4) and fd7a:115c:a1e0::/48 (IPv6) ranges for compatibility with official Tailscale clients.

Client Setup (hs0)

To connect a node to your own Headscale instance:

  1. Add "hs0" to the modules.profiles.networks list.
  2. The hs0 profile (modules/profiles/network/hs0.nix) pre-configures the --login-server flag.
  3. 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

ScenarioKey ExpiryMitigation
User login (tailscale up)180 daysRe-auth with tailscale up
Tagged via auth keyDisabledNone needed — permanent
Tagged before 2022-03-10Enabled until re-authRe-authenticate once
Tags changed via admin consoleUnchangedRe-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.rules in the sing-box config (see docs/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:

CauseDiagnosisFix
Node is powered off / sleepingtailscale status shows offlinePower 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 UDPtailscale ping <node> fails or times outAllow outbound UDP on port 41641. Check iptables -L and sing-box route rules.
Double NAT / CGNATBoth nodes behind NAT with no UPnPISP 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 UDPip route get 100.64.243.70 shows tun0 instead of tailscale0Ensure 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 regionNodes in different geographic regions with no overlapping DERPAdd 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 shows direct once online
  • sing-box config — verify route_exclude_address includes 100.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/24 switch 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):

SettingValueWhen applied
local_kbps2,147,483 (~2.1 Gbps)Server IP is RFC 1918 private (e.g. 192.168.31.12)
remote_kbps10,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" to modules.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:

  1. Generate keypair on the new client
  2. Add the client’s public key to the server’s peer list in wg0.nix
  3. 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/24 and fd2e:a01c:d041::/64 through 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”:

  1. VPS Ingress: A public VPS (like vps-pacman) acts as a gateway.
  2. Internal Forwarding: Nginx on the VPS proxies traffic over the Tailscale/Headscale IP to a backend homelab node.
  3. Real-IP Preservation: Every backend service automatically restores the original client IP from the X-Forwarded-For header 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)

  1. Open App CenterCommunications
  2. Find TailscaleInstall
  3. After install, click the Tailscale icon → Open
  4. Log in to your Tailscale network → Connect

The App Center version may lag behind upstream releases.

Download the latest stable QPKG from pkgs.tailscale.com/stable/#qpkgs:

ArchitectureQNAP Model ExampleFile
x86_64TS-453D, TS-873A, TVS-872XTailscale_{ver}-1_x86_64.qpkg
arm_64TS-133, TS-233Tailscale_{ver}-1_arm_64.qpkg
arm-x41TS-431K, TS-231KTailscale_{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:

  1. Generate an auth key in the Tailscale admin console:

    • Reusable: Enabled
    • Ephemeral: Disabled
    • Tags: tag:nas
  2. 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
  3. Verify: The device appears in the admin console as nas-nasa with tag tag:nas and key expiry Disabled.

Once tagged, the NAS stays permanently connected — no 180-day re-auth.

Features

FeatureHow
TaildropFiles sent to the NAS appear in the Tailnet shared folder (auto-created)
Subnet routertailscale up --advertise-routes=192.168.31.0/24 to expose LAN
Exit nodetailscale up --advertise-exit-node then approve in admin console
SSH over TailscaleEnabled by default — SSH to nas-nasa or its Tailscale IP from any node
HTTPSTailscale 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

DetailValue
DeviceQNAP TS-453D
Architecturex86_64
LAN IP192.168.31.12
Tailscale IP100.64.243.70
AuthTagged (tag:nas), key expiry disabled
Subnet routes192.168.31.0/24 (advertised to tailnet)
ServicesQNAP 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 in id3-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

  1. Download the QPKG from the official Jellyfin releases or the QNAP Club repository.
  2. In App CenterInstall Manually → select the .qpkgInstall.
  3. 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:

  1. Open App Center → click the gear icon ⚙ (Settings) in the top right.
  2. Switch to the App Repository tab → click Add.
  3. Fill in:
    • Name: pdulvp-jellyfin (or any descriptive name)
    • URL: https://pdulvp.github.io/qnap-store/repos.xml
  4. Click Add → close settings.
  5. 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.