WireGuard: The Foundation

Everything starts with WireGuard. WireGuard is a VPN protocol that does one thing exceptionally well: create encrypted point-to-point tunnels with minimal configuration.

A complete WireGuard config looks like this:

# /etc/wireguard/wg0.conf on Node A
[Interface]
PrivateKey = yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=
Address = 10.0.0.1/24
ListenPort = 51820

[Peer]
PublicKey = xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=
AllowedIPs = 10.0.0.2/32
Endpoint = 203.0.113.50:51820

That's it. No certificates, no certificate authorities, no HMAC negotiation. Just Curve25519 key pairs and ChaCha20-Poly1305 encryption. The protocol fits in about 4,000 lines of kernel code.

But raw WireGuard has a fundamental problem for real-world use: you need to know every peer's IP address and port in advance. If Node B is behind a NAT, its public endpoint changes. If Node C is on a mobile network, its IP changes every time it switches towers. With 50 nodes, you'd need to update 2,450 peer configurations every time an IP changes. That is where Tailscale comes in.

Tailscale's Architecture

Tailscale is, at its core, a coordination system that solves WireGuard's configuration problem. Here's the architecture:

┌─────────────────────────────────────────────┐
│ Coordination Server │
│ (Tailscale SaaS or Headscale self-hosted) │
│ │
│ • Node registry (public keys, endpoints) │
│ • ACL policy engine │
│ • DNS configuration │
│ • OIDC authentication │
└──────────────┬───────────────────────────────┘
 │ HTTPS (control plane only)
 ┌──────────┼──────────┐
 ▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│ Node A │ │ Node B │ │ Node C │
│tailsca-│ │tailsca-│ │tailsca-│
│le agent│ │le agent│ │le agent│
└───┬────┘ └───┬────┘ └───┬────┘
 │ │ │
 └──────────┼──────────┘
 WireGuard tunnels
 (direct, peer-to-peer)

The coordination server never sees your data traffic. It only handles:

  1. Registering nodes and their public keys
  2. Distributing endpoint information (IP addresses, ports)
  3. Evaluating ACL policies
  4. Coordinating NAT traversal

All actual data flows directly between nodes over WireGuard. The coordination server is a control plane, not a data plane.

NAT Traversal: The Hard Part

Getting two devices behind NAT to talk directly is the hardest networking problem Tailscale solves. There are several NAT types, and each requires different strategies:

STUN: Discovering Your Public Address

The first step is figuring out your own public IP and port. Tailscale uses STUN (Session Traversal Utilities for NAT):

Node A (behind NAT) STUN Server Node B (behind NAT)
 │ │ │
 │── STUN Binding Request ──→ │ │
 │ │ │
 │←── Your public IP:port ────│ │
 │ (203.0.113.50:34567) │ │
 │ │ │
 │ │ ←── STUN Request ────│
 │ │ │
 │ │── Your public IP ──→ │
 │ │ (198.51.100.22: │
 │ │ 45678) │

Tailscale runs STUN servers embedded in their DERP infrastructure. The tailscale netcheck command queries multiple STUN servers to determine:

  • Your public IP address
  • The type of NAT you're behind (full cone, restricted cone, port-restricted, symmetric)
  • Whether UDP is available at all

Hole Punching

Once both nodes know their public addresses (from STUN), they try UDP hole punching:

Node A (NAT: 203.0.113.50:34567) Node B (NAT: 198.51.100.22:45678)
 │ │
 │── UDP packet to 198.51.100.22:45678 ─→│ (may be dropped by B's NAT)
 │ │
 │←── UDP packet to 203.0.113.50:34567 ──│ (may be dropped by A's NAT)
 │ │
 │── UDP packet (retransmit) ───────────→│ (NOW B's NAT has a mapping)
 │ │
 │←── UDP packet (retransmit) ───────────│ (NOW A's NAT has a mapping)
 │ │
 │◄═══════ WireGuard tunnel ═══════════►│ (direct connection!)

Both sides simultaneously send UDP packets to each other's public address. The first packets may be dropped because the NAT hasn't seen traffic from that source yet. But the outbound packet creates a NAT mapping, so when the other side's packet arrives, the NAT lets it through.

This works for full cone, restricted cone, and port-restricted NAT. For symmetric NAT (where the port changes per destination), hole punching fails because the STUN-discovered port won't match the port used for the peer connection.

Port Prediction and Birthday Attacks

For harder NAT types, Tailscale tries port prediction. If a NAT assigns sequential ports (many do), you can predict the next port:

// Simplified port prediction logic
func predictPorts(observedPorts []uint16) []uint16 {
 if len(observedPorts) < 2 {
 return nil
 }
 // Check for sequential pattern
 delta := observedPorts[1] - observedPorts[0]
 predicted := observedPorts[len(observedPorts)-1] + delta

 // Return a range around the predicted port
 candidates := make([]uint16, 0, 32)
 for i := predicted - 16; i <= predicted + 16; i++ {
 candidates = append(candidates, i)
 }
 return candidates
}

When port prediction isn't possible, Tailscale uses a "birthday attack" approach, both sides simultaneously probe many ports, hoping for a collision. With 256 probes from each side, there's about a 50% chance of hitting the right port combination (birthday paradox math).

DERP: When Direct Connections Fail

When hole punching fails (symmetric NAT on both sides, or UDP blocked entirely), Tailscale falls back to DERP, Designated Encrypted Relay for Packets.

DERP servers are essentially WebSocket relays that forward encrypted WireGuard packets:

Node A DERP Server Node B
 │ │ │
 │── WG encrypted ─────→│ │
 │ packet over HTTPS │── WG encrypted ──────→ │
 │ │ packet over HTTPS │
 │ │ │
 │←── WG encrypted ─────│←── WG encrypted ──────│
 │ packet over HTTPS │ packet over HTTPS │

Key points about DERP:

  • DERP servers can't decrypt traffic. They only relay WireGuard-encrypted packets. The DERP server sees encrypted blobs.
  • DERP runs over HTTPS (TCP port 443). This works through corporate firewalls and captive portals that block UDP.
  • Tailscale runs DERP servers globally. Traffic routes through the nearest DERP server based on latency.
  • DERP is a fallback, not the default. Tailscale continuously tries to establish direct connections and only uses DERP when necessary.

You can run your own DERP server:

# Build the DERP server
go install tailscale.com/cmd/derper@latest

# Run it
derper \
 --hostname=derp.example.com \
 --certmode=letsencrypt \
 --certdir=/var/lib/derper/certs \
 --stun \
 --verify-clients

MagicDNS: Names Instead of IPs

MagicDNS is Tailscale's automatic DNS system. Every node gets a human-readable name on your tailnet:

laptop.tail12345.ts.net → 100.64.0.1
server.tail12345.ts.net → 100.64.0.2
nas.tail12345.ts.net → 100.64.0.3

Under the hood, Tailscale runs a local DNS resolver (usually on 100.100.100.100) that:

  1. Intercepts DNS queries for your tailnet domain
  2. Resolves them from the node list provided by the coordination server
  3. Forwards all other queries to your configured upstream DNS
# Check MagicDNS resolution
$ tailscale status
100.64.0.1 laptop linux -
100.64.0.2 server linux active; direct 203.0.113.50:41641
100.64.0.3 nas linux active; relay "nyc"

$ dig server.tail12345.ts.net @100.100.100.100
;; ANSWER SECTION:
server.tail12345.ts.net. 300 IN A 100.64.0.2

Building Your Own: Headscale

Headscale is an open-source implementation of the Tailscale coordination server. It's compatible with the official Tailscale client, so you get all the NAT traversal and WireGuard magic without depending on Tailscale's infrastructure.

Setting Up Headscale

# Install Headscale
wget https://github.com/juanfont/headscale/releases/download/v0.23.0/headscale_0.23.0_linux_amd64.deb
sudo dpkg -i headscale_0.23.0_linux_amd64.deb

Configure it:

# /etc/headscale/config.yaml
server_url: https://headscale.example.com:443
listen_addr: 0.0.0.0:443

# TLS
tls_cert_path: /etc/headscale/tls/cert.pem
tls_key_path: /etc/headscale/tls/key.pem

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

# Network configuration
ip_prefixes:
 - 100.64.0.0/10
 - fd7a:115c:a1e0::/48

# DNS
dns_config:
 nameservers:
 - 1.1.1.1
 - 8.8.8.8
 magic_dns: true
 base_domain: mesh.example.com
 domains: []

# DERP
derp:
 server:
 enabled: true
 region_id: 999
 region_code: "custom"
 region_name: "My DERP"
 stun_listen_addr: 0.0.0.0:3478
 urls: [] # Don't use Tailscale's DERP servers
 paths: []
 auto_update_enabled: false
 update_frequency: 24h

# ACL policy
acl_policy_path: /etc/headscale/acl.json

# OIDC authentication
oidc:
 issuer: https://auth.example.com
 client_id: headscale
 client_secret: your-secret
 scope: ["openid", "profile", "email"]

Register Nodes

# Create a user (namespace)
headscale users create engineering

# Generate a pre-auth key
headscale preauthkeys create --user engineering --reusable --expiration 24h
# Output: key-abc123def456...

# On the client node, join using the official Tailscale client
tailscale up --login-server https://headscale.example.com --authkey key-abc123def456

ACL Policies

Headscale supports Tailscale's ACL policy format:

{
 "acls": [
 {
 "action": "accept",
 "src": ["group:engineering"],
 "dst": ["tag:production:443", "tag:production:22"]
 },
 {
 "action": "accept",
 "src": ["group:ops"],
 "dst": ["*:*"]
 },
 {
 "action": "accept",
 "src": ["autogroup:member"],
 "dst": ["autogroup:self:*"]
 }
 ],
 "groups": {
 "group:engineering": ["user1@example.com", "user2@example.com"],
 "group:ops": ["admin@example.com"]
 },
 "tagOwners": {
 "tag:production": ["group:ops"],
 "tag:staging": ["group:engineering"]
 }
}

This gives the engineering team access to ports 443 and 22 on production-tagged machines, while ops gets full access to everything.

Advanced: Subnet Routing and Exit Nodes

Subnet Router

A subnet router exposes an entire network to your tailnet without installing Tailscale on every device:

# On the subnet router node
echo 'net.ipv4.ip_forward = 1' | sudo tee -a /etc/sysctl.conf
echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.conf
sudo sysctl -p

# Advertise the local subnet
tailscale up --advertise-routes=192.168.1.0/24,10.0.0.0/8

# On headscale server, approve the routes
headscale routes enable --route 192.168.1.0/24
headscale routes enable --route 10.0.0.0/8

Now every node on your tailnet can reach 192.168.1.0/24 through the subnet router, without any changes to devices on that subnet.

Exit Node

An exit node routes all internet traffic through a specific node, useful for accessing region-restricted services or routing through a trusted network:

# On the exit node
tailscale up --advertise-exit-node

# On headscale, approve it
headscale routes enable --route 0.0.0.0/0
headscale routes enable --route ::/0

# On the client, use the exit node
tailscale up --exit-node=server.mesh.example.com

Monitoring Your Mesh

Here is a script for monitoring mesh health:

#!/bin/bash
# mesh-health.sh - Monitor Tailscale/Headscale mesh connectivity

echo "=== Mesh Health Report ==="
echo "Date: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo ""

# Get status as JSON
STATUS=$(tailscale status --json)

# Count peers by connection type
DIRECT=$(echo "$STATUS" | jq '[.Peer[] | select(.CurAddr != "")] | length')
RELAY=$(echo "$STATUS" | jq '[.Peer[] | select(.Relay != "" and .CurAddr == "")] | length')
OFFLINE=$(echo "$STATUS" | jq '[.Peer[] | select(.Online == false)] | length')
TOTAL=$(echo "$STATUS" | jq '.Peer | length')

echo "Peers: $TOTAL total, $DIRECT direct, $RELAY relayed, $OFFLINE offline"
echo ""

# List relayed peers (these need attention)
echo "=== Relayed Peers (suboptimal) ==="
echo "$STATUS" | jq -r '.Peer[] | select(.Relay != "" and .CurAddr == "") |
 "\(.HostName)\t\(.TailscaleIPs[0])\trelay: \(.Relay)"'
echo ""

# Latency check to all online peers
echo "=== Latency Check ==="
for ip in $(echo "$STATUS" | jq -r '.Peer[] | select(.Online == true) | .TailscaleIPs[0]'); do
 HOST=$(echo "$STATUS" | jq -r ".Peer[] | select(.TailscaleIPs[0] == \"$ip\") | .HostName")
 LATENCY=$(ping -c 3 -W 2 "$ip" 2>/dev/null | tail -1 | awk -F'/' '{print $5}')
 if [ -n "$LATENCY" ]; then
 echo "$HOST ($ip): ${LATENCY}ms avg"
 else
 echo "$HOST ($ip): UNREACHABLE"
 fi
done

Performance: WireGuard vs OpenVPN vs IPSec

These benchmarks were run on two machines connected over a 1Gbps link through a NAT:

Protocol Throughput Latency Overhead CPU Usage
No VPN (baseline) 940 Mbps 0ms -
WireGuard 890 Mbps 0.3ms 2%
OpenVPN (UDP) 620 Mbps 1.2ms 15%
OpenVPN (TCP) 410 Mbps 2.8ms 22%
IPSec (IKEv2) 820 Mbps 0.5ms 5%

WireGuard's performance advantage comes from:

  • Kernel-space execution: The data path runs in the kernel, avoiding user-space context switches
  • Modern cryptography: ChaCha20-Poly1305 is faster than AES-GCM on CPUs without AES-NI (common in ARM/mobile)
  • Minimal protocol overhead: No handshake negotiation, no cipher suite selection, no session resumption
  • Fixed-size headers: 32-byte overhead per packet vs OpenVPN's variable-size headers

The State of the Art in 2026

Tailscale and the WireGuard mesh ecosystem have matured considerably:

  • Tailscale Funnel: Expose specific services on your tailnet to the public internet with automatic TLS. This is huge for development and demos.
  • Taildrop: Encrypted file sharing between nodes. Like AirDrop, but across any device on your tailnet.
  • Headscale 0.23+: Full compatibility with Tailscale client features, including MagicDNS, subnet routing, and exit nodes.
  • Netbird and Nebula: Alternative mesh networking projects with different design philosophies. Netbird is closer to Tailscale; Nebula (by Slack) uses a certificate-based trust model.

The mesh VPN space has gone from "enterprise-only nightmare" to "install one binary and it works." WireGuard's simplicity made this possible, and Tailscale/Headscale made it accessible. For those still managing OpenVPN configs or SSH tunnels for internal connectivity, this approach is worth evaluating. The setup time is measured in minutes, not days.