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:
- Registering nodes and their public keys
- Distributing endpoint information (IP addresses, ports)
- Evaluating ACL policies
- 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:
- Intercepts DNS queries for your tailnet domain
- Resolves them from the node list provided by the coordination server
- 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.