LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Tailscale and the Zero-Trust Home Network

networkinghomelabvpnsecuritywireguardself-hosteddevops

The traditional answer to “I want to access my home network remotely” is a VPN server: install OpenVPN or WireGuard on a box with a public IP, open a firewall port, distribute certificates or keys to clients, and manage it forever. It works, but it requires a machine with a reachable public IP, manual key distribution, and ongoing maintenance. If you have two networks you want to connect, you do it again. If you add a team member who needs access, you generate keys by hand.

Tailscale takes a different approach. It handles the key distribution, the NAT traversal, and the coordination automatically. You install a client on each device, authenticate once, and the devices form a mesh network where every node can reach every other node directly — no central gateway, no traffic bottleneck, no single point of failure. The underlying encryption is WireGuard. The magic is everything Tailscale builds on top of it.

This post covers how the system actually works, the full feature set (subnet routing, exit nodes, Funnel, MagicDNS, ACLs, Tailscale SSH), and how to self-host the coordination layer with Headscale if you want complete independence from Tailscale’s cloud.


How It Works: The Two Planes

Tailscale separates its operation into a control plane and a data plane. Understanding this separation explains why the system is reliable even when Tailscale’s servers are unreachable, and why it behaves so differently from a traditional hub-and-spoke VPN.

The control plane is login.tailscale.com — Tailscale’s coordination server. Its job is key distribution and policy delivery. Each device generates a WireGuard keypair locally; the private key never leaves the device. The public key is uploaded to the coordination server. When device A wants to communicate with device B, the coordination server gives each device the other’s public key, IP address, and DERP relay assignment. That’s it. The control plane carries no user traffic — only tiny metadata messages.

The data plane is a direct WireGuard mesh. Once devices have each other’s public keys (from the coordination server), they communicate directly, peer-to-peer, using WireGuard. The coordination server is no longer involved. If Tailscale’s servers went down after your devices have connected, your existing connections would continue to work.

                   ┌─────────────────────────────────┐
                   │    login.tailscale.com           │
                   │    (coordination server)         │
                   │                                  │
                   │  - stores public keys            │
                   │  - delivers ACL policies         │
                   │  - assigns DERP regions          │
                   │  - NO user traffic passes here   │
                   └──────┬───────────────┬───────────┘
                          │ keys+policy   │ keys+policy
                          │               │
                   ┌──────▼──┐       ┌────▼──────┐
                   │ Laptop  │◄─────►│  Server   │
                   │ 100.x.y │ direct│  100.a.b  │
                   │  .1     │ WireG │  .2       │
                   └─────────┘ uard  └───────────┘

Each device on a Tailscale network (called a tailnet) gets a stable IP address in the 100.64.0.0/10 range (Carrier-Grade NAT space, reserved for this purpose). These addresses are stable across networks and locations — your laptop is always 100.x.y.z regardless of whether it’s on home WiFi, a coffee shop, or a cellular connection.


NAT Traversal: How Direct Connections Happen

The hard problem Tailscale solves is connecting two devices that are both behind NAT — both on home routers, corporate firewalls, or cloud VPCs that do not have publicly reachable IPs. This describes most real-world devices.

The technique is called UDP hole punching, and it works because most NAT devices use predictable port mappings for outbound UDP traffic.

  1. Both devices report their NAT-external address and port to the coordination server (discovered via STUN — the same protocol WebRTC uses).
  2. The coordination server shares these external addresses with both peers simultaneously.
  3. Both devices send UDP packets to each other’s external address at the same time.
  4. Each NAT device sees an outbound packet to the other peer’s address and creates a mapping. When the return packet arrives from that same address, the NAT forwards it through — because outbound traffic already created the hole.
Laptop (192.168.1.5)          Server (10.0.0.3)
behind home router            behind cloud VPC NAT
external: 203.0.113.10:42000  external: 198.51.100.5:51820

Step 1: Both report externals to coordination server
Step 2: Coordination server shares: "Laptop is at 203.0.113.10:42000"
                                    "Server is at 198.51.100.5:51820"
Step 3: Laptop sends UDP to 198.51.100.5:51820  ─┐ simultaneous
        Server sends UDP to 203.0.113.10:42000  ─┘
Step 4: Each NAT accepts the return traffic → direct connection established

This works for most NAT configurations. The failure case is symmetric NAT, which assigns a different external port for every destination. Some corporate firewalls and certain carrier-grade NAT implementations use symmetric NAT, making the external port unpredictable and hole punching impossible.

For those cases, Tailscale falls back to DERP (Designated Encrypted Relay for Packets). DERP servers are hosted by Tailscale in multiple geographic regions. When a direct connection cannot be established, traffic is relayed through the closest DERP server. Critically, DERP traffic is still end-to-end WireGuard encrypted — the DERP server relays encrypted packets it cannot read. You trade latency for reliability.

Connection establishment sequence:
1. Try direct connection (hole punching) ─── success → use direct
2. Try peer relay                         ─── success → use peer relay
3. Fall back to DERP relay               ─── always works, higher latency

You can check which connection type is active with tailscale status — it shows direct or relay for each peer along with the latency.


Installation and Basic Setup

The client is available for Linux, macOS, Windows, iOS, Android, and as a Docker image. On most platforms:

1
2
3
4
5
6
7
8
# Linux (Debian/Ubuntu)
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up

# This opens a browser for authentication.
# After auth, the device is on your tailnet.
tailscale status    # shows all devices and their IPs
tailscale ip        # shows this device's tailnet IP

Every device you add authenticates through your identity provider (Google, GitHub, Microsoft, or SAML/OIDC for enterprise). The authentication step is one-time — after that, the device is trusted until you revoke it. No key files to distribute, no certificate renewals, no authorized_keys management.


Subnet Routing

A subnet router advertises routes for an entire private subnet to the tailnet. Devices on that subnet do not need Tailscale installed — they are reachable through the subnet router.

This is the primary use case for homelab access: install Tailscale on one machine (a Pi, a NAS, any always-on device), advertise your home LAN’s subnet, and every device on your tailnet can reach every device on your home network by its normal LAN IP — NAS, printer, IoT devices, switches, anything.

1
2
3
4
5
6
7
8
9
# On the subnet router (must have IP forwarding enabled)
echo 'net.ipv4.ip_forward = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf
echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf
sudo sysctl -p /etc/sysctl.d/99-tailscale.conf

sudo tailscale up --advertise-routes=192.168.1.0/24

# In the Tailscale admin console (or via policy file):
# Approve the advertised route — routes require explicit approval

After approval, devices on your tailnet can reach 192.168.1.x addresses directly through the subnet router. The traffic path:

Your laptop (traveling) → tailnet → subnet router → home LAN device
100.x.y.z               WireGuard  192.168.1.10     192.168.1.50

For multiple sites — homelab plus a cloud VPC, or homelab plus a remote office — run a subnet router at each site advertising its local subnet. All sites connect through the tailnet mesh without any additional configuration. There is no VPN gateway to configure, no IPSec tunnel to maintain.

1
2
3
4
5
6
7
# Homelab router advertises home LAN
sudo tailscale up --advertise-routes=192.168.1.0/24

# Cloud VPC router advertises VPC subnet
sudo tailscale up --advertise-routes=10.0.0.0/16

# Devices on either subnet can now reach devices on the other

Exit Nodes

An exit node routes all internet traffic from a tailnet device through another tailnet device. This is the conventional VPN use case: your traffic appears to come from the exit node’s location.

1
2
3
4
5
6
7
8
9
# Enable a device as an exit node
sudo tailscale up --advertise-exit-node

# Approve it in the admin console, then on any client:
sudo tailscale up --exit-node=100.x.y.z        # by tailnet IP
sudo tailscale up --exit-node=homelab-server   # by MagicDNS name

# Allow access to local network resources even when routing through exit node
sudo tailscale up --exit-node=100.x.y.z --exit-node-allow-lan-access

Practical uses: route traffic through a homelab server to bypass geo-restrictions, use a cloud server as an exit node for consistent egress IP (useful for services that whitelist IPs), or route sensitive work traffic through a known-good corporate egress.

The important distinction from subnet routing: exit nodes affect all internet traffic. Subnet routers only affect traffic destined for the advertised subnet. A device can be both simultaneously, though Tailscale recommends separating the roles onto two different machines to avoid routing table conflicts.


MagicDNS

MagicDNS automatically assigns human-readable DNS names to every device in your tailnet. Instead of remembering 100.x.y.z, you use homelab-server or the fully qualified homelab-server.your-tailnet.ts.net.

Enable it in the DNS settings of your admin console. Once on, every device is reachable by its machine name from any other tailnet device. Names are stable — the name follows the machine, not the IP.

You can also configure custom DNS in MagicDNS to split-DNS your internal domains:

# In the Tailscale admin console → DNS:
# Custom nameservers:
#   For domain: internal.example.com → 192.168.1.53 (your internal DNS)
#   Global nameserver: 1.1.1.1

# Result: queries for *.internal.example.com go to your internal DNS
#         all other queries go to 1.1.1.1
# Works for all devices on the tailnet automatically

This replaces the common homelab pattern of either running a split-horizon DNS server that requires VPN to reach, or hardcoding internal IP addresses everywhere.


Tailscale SSH

Tailscale SSH lets you SSH to tailnet devices without managing SSH keys at all. Authentication is handled by the tailnet identity — if your ACL policy allows it, ssh user@device works, and Tailscale handles the session authentication transparently.

Enable it on a node:

1
sudo tailscale up --ssh

Then in your tailnet policy file, grant access:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{
  "ssh": [
    {
      "action": "accept",
      "src": ["tag:ops", "group:admins"],
      "dst": ["tag:servers"],
      "users": ["autogroup:nonroot", "ops"]
    },
    {
      "action": "check",
      "src": ["autogroup:members"],
      "dst": ["tag:servers"],
      "users": ["autogroup:nonroot"],
      "checkPeriod": "12h"
    }
  ]
}

The check action enables session recording and requires periodic re-authentication, making it suitable for production access patterns where you want an audit trail. Sessions can be recorded to S3 or a local directory.

The practical benefit for homelabs: no ~/.ssh/authorized_keys management. Add a new user to your tailnet and the right ACL group, and they can SSH to the appropriate machines immediately. Revoke them from Tailscale and access is gone instantly — no hunting for SSH keys to delete.


Funnel: Public HTTPS Without a Server

Funnel exposes a local service to the public internet over HTTPS without requiring a public IP, a cloud server, or any inbound firewall rules. Tailscale’s infrastructure proxies inbound traffic to your device.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Expose a local service running on port 3000
sudo tailscale funnel 3000

# Service is now accessible at:
# https://your-machine.your-tailnet.ts.net

# Expose a specific path
sudo tailscale funnel --set-path /api 3000

# Expose a static directory
sudo tailscale funnel /home/ops/public

# Run persistently in background (survives reboots)
sudo tailscale funnel --bg 3000

# Check current funnel config
tailscale funnel status

# Tear it down
sudo tailscale funnel --bg=false

Tailscale automatically provisions a TLS certificate for the *.ts.net subdomain. The traffic is encrypted in transit and Tailscale terminates TLS at their edge, then re-encrypts for the last hop to your device (or delivers plaintext to localhost — check the docs for your specific use case).

Serve is the complement to Funnel — it exposes a service only within your tailnet, not the public internet:

1
2
3
4
5
# Expose localhost:8080 as https://hostname.tailnet.ts.net (tailnet only)
tailscale serve 8080

# Proxy /metrics path to a local Prometheus endpoint
tailscale serve --set-path /metrics http://localhost:9090/metrics

Funnel is not a replacement for a proper reverse proxy under sustained traffic — it is designed for developer sharing, webhooks, temporary public access, and homelab services where you do not have an inbound-capable server. For a production public service, a VPS with nginx/Caddy is still the right answer.


ACL Policies: Access Control

The tailnet policy file is a JSON document that defines who can reach what. By default, all devices on a tailnet can reach all other devices. The ACL file lets you restrict this.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
{
  "tagOwners": {
    "tag:servers": ["group:admins"],
    "tag:iot":     ["group:admins"],
    "tag:media":   ["group:admins"]
  },

  "groups": {
    "group:admins": ["ops@example.com"],
    "group:family": ["ops@example.com", "partner@example.com"]
  },

  "acls": [
    // Admins can reach everything
    {
      "action": "accept",
      "src":    ["group:admins"],
      "dst":    ["*:*"]
    },
    // Family can reach media server on specific ports only
    {
      "action": "accept",
      "src":    ["group:family"],
      "dst":    ["tag:media:8096", "tag:media:443"]  // Jellyfin + HTTPS
    },
    // IoT devices cannot initiate connections to anything
    // (they can only receive inbound connections, per the above rules)
  ],

  "ssh": [
    {
      "action": "accept",
      "src":    ["group:admins"],
      "dst":    ["tag:servers"],
      "users":  ["ops", "root"]
    }
  ]
}

Tags assign a role to a device independent of who owns it. A tag:servers device is a server regardless of which user registered it. This is important because if the user who registered a device leaves the organization and their account is removed, untagged devices disappear with them. Tagged devices persist.

The policy file is version-controlled (Tailscale’s admin console shows a diff view and history), and changes are applied to all devices in the tailnet within seconds.


Headscale: Self-Hosting the Control Plane

Headscale is an open-source reimplementation of the Tailscale coordination server. Your devices still use the official Tailscale client software; only the coordination server is replaced. The WireGuard mesh, NAT traversal, and DERP relay selection all function the same way — though for DERP, you can either continue using Tailscale’s public DERP infrastructure or run your own DERP servers.

Reasons to run Headscale: complete metadata sovereignty (no public keys or device information leaves your infrastructure), independence from Tailscale’s pricing and account policies, and the ability to run a tailnet with more than the free tier’s device limit without a subscription.

The tradeoff: Headscale implements most but not all Tailscale features. As of 2025, Tailscale SSH, Funnel, and some enterprise ACL features are absent or partial. For homelab use, the feature set is generally sufficient.

Deploying Headscale with Docker:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# docker-compose.yml
services:
  headscale:
    image: headscale/headscale:latest
    restart: unless-stopped
    ports:
      - "8080:8080"
      - "9090:9090"    # metrics
    volumes:
      - ./config:/etc/headscale
      - ./data:/var/lib/headscale
    command: serve

  headplane:
    image: ghcr.io/tale/headplane:latest   # web UI for Headscale
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      - HEADSCALE_URL=http://headscale:8080
      - HEADSCALE_API_KEY=${HEADSCALE_API_KEY}
    depends_on:
      - headscale
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# config/config.yaml
server_url: https://headscale.yourdomain.com
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 0.0.0.0:9090

private_key_path: /var/lib/headscale/private.key
noise:
  private_key_path: /var/lib/headscale/noise_private.key

ip_prefixes:
  - 100.64.0.0/10

db_type: sqlite3
db_path: /var/lib/headscale/db.sqlite

dns_config:
  magic_dns: true
  base_domain: home.internal
  nameservers:
    - 1.1.1.1

log:
  level: info

Headscale must be accessible from the public internet (or at least from all devices that will join the network) — it needs a reachable address and port for device registration. A VPS with a public IP is the typical host. Put nginx or Caddy in front for TLS termination.

Basic operations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Create a user (Headscale's term for a namespace/tenant)
docker exec headscale headscale users create homelab

# Generate a pre-auth key for device registration
docker exec headscale headscale preauthkeys create --user homelab --reusable --expiration 24h

# On a device, register with Headscale instead of Tailscale's servers:
sudo tailscale up \
  --login-server https://headscale.yourdomain.com \
  --auth-key <preauthkey-from-above>

# List connected nodes
docker exec headscale headscale nodes list

# Generate routes (after a node advertises subnet routes)
docker exec headscale headscale routes list
docker exec headscale headscale routes enable --route <id>

After registering, the device behaves identically to a Tailscale-coordinated device: same WireGuard mesh, same 100.x.y.z addressing, same tailscale status output. The only difference is which server coordinates the key exchange.


Practical Homelab Patterns

Pattern 1: Remote access to everything on your LAN

Install Tailscale on any always-on machine (Pi, NAS, mini-PC). Advertise your LAN subnet. Every device you own that has Tailscale installed can reach your entire home network from anywhere — NAS, Proxmox web UI, IPMI consoles, everything.

1
2
# On the always-on machine
sudo tailscale up --advertise-routes=192.168.1.0/24 --accept-routes

Pattern 2: Multi-site mesh with no central VPN gateway

Homelab + cloud VPC + remote office. Each site has one Tailscale device advertising its subnet. All sites form a mesh automatically. No site is “the hub” — any site can reach any other directly.

Pattern 3: Secure IoT isolation

Tag IoT devices (tag:iot). Write ACL rules that prevent IoT devices from initiating connections to any other devices, but allow your management machines to reach them. IoT devices can still reach the internet through their normal LAN gateway — only tailnet-initiated connections are restricted.

Pattern 4: Webhook receiver without a public server

Use tailscale funnel 3000 on your development machine. Paste the *.ts.net URL into the webhook configuration. Receive webhooks from GitHub, Stripe, Twilio directly on your laptop without ngrok. Works through any NAT, no account required.

Pattern 5: Shared access for family or small team

Add users to your tailnet with ACL rules that restrict them to specific services. Revoke access by removing them from Tailscale — no SSH key hunting, no firewall rule cleanup.


Limits and Honest Trade-offs

The free tier allows up to 3 users and 100 devices, which is plenty for personal homelab use. Beyond that, the Personal plan ($6/user/month as of 2025) is needed for features like custom ACLs per-user, and Teams/Enterprise plans for SSO and advanced features.

Relying on Tailscale’s control plane means accepting that if Tailscale’s servers are unreachable for an extended period, you cannot add new devices or push ACL changes. Existing connections between already-connected devices continue working (the mesh is peer-to-peer), but new registrations fail. Headscale eliminates this dependency entirely.

Not all NAT penetration succeeds. Symmetric NAT — found in some enterprise firewalls and certain ISPs — defeats UDP hole punching. Traffic falls back to DERP relay, which adds latency (typically 10-40ms extra depending on which DERP region is closest). For interactive SSH sessions this is imperceptible; for latency-sensitive applications it may matter.

DERP relay traffic is encrypted but visible in volume. Tailscale can observe that traffic is flowing between two of your devices and approximately how much, even though they cannot read its content. For most users this is acceptable; for high-security or compliance scenarios it is worth either running your own DERP servers or evaluating whether Headscale’s full self-hosting model is warranted.

Tailscale is not a replacement for network segmentation at the host level. ACLs in the policy file control which tailnet IPs can reach which services. They do not replace firewall rules on the devices themselves, and they do not provide defense-in-depth if a device is compromised. Run local firewalls on sensitive services in addition to tailnet ACLs, not instead of them.

For most homelab operators, Tailscale is the lowest-friction path to a functional zero-trust network that would have required significant infrastructure and maintenance investment five years ago. The free tier handles most personal use cases. When you hit its limits or want full data sovereignty, Headscale is a straightforward migration — change --login-server and re-register your devices.

Comments