<!-- Canonical: https://lucioduran.com/blog/wireguard-mesh-networking-tailscale-internals
Language: en
Content-SHA256: 525c4e480f77fe00d4387dfbd575bacbc2c111bfa9e8e6502edc3f0cea7cf56f
-->

Networking

# WireGuard Mesh Networking: Tailscale and Headscale Internals Explained

2026-01-2811 min read

Lucio Durán
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/wireguard-mesh-networking-tailscale-internals>)

## WireGuard: The Foundation

Everything starts with [WireGuard](<https://www.wireguard.com/>). 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:

```ini
# /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](<https://tailscale.com/>) 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:

```go
// 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:

```bash
# 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

```bash
# 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](<https://github.com/juanfont/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

```bash
# 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:

```yaml
# /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

```bash
# 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:

```json
{
 "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:

```bash
# 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:

```bash
# 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:

```bash
#!/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.

## Frequently asked questions

### What is Tailscale?

Tailscale is a mesh VPN service built on WireGuard that creates a secure network between your devices. It handles NAT traversal, key exchange, and DNS automatically, so devices can communicate directly regardless of their network location.

### What is Headscale?

Headscale is an open-source, self-hosted implementation of the Tailscale coordination server. It lets you run your own Tailscale-compatible network without depending on Tailscale's infrastructure.

### What are DERP servers?

DERP (Designated Encrypted Relay for Packets) servers are Tailscale's relay infrastructure. When two devices can't establish a direct WireGuard connection (due to restrictive NAT), traffic is encrypted and relayed through the nearest DERP server.

### Is Tailscale traffic always encrypted?

Yes. All traffic uses WireGuard encryption (ChaCha20-Poly1305) regardless of whether it's a direct connection or relayed through DERP. DERP servers cannot see the plaintext, they only forward encrypted WireGuard packets.

## Tips

1. **Tip:** Run ` tailscale netcheck ` to diagnose connectivity issues. It shows your NAT type, DERP latency, and whether UDP is being blocked. If you see ` NAT type: hard ` on both sides, direct connections are impossible and traffic will relay through DERP.
2. **Tip:** When running Headscale, set ` dns_config.magic_dns: true ` and ` dns_config.base_domain: your.domain ` in the config. This gives you automatic DNS resolution for all nodes without touching ` /etc/hosts ` or running a separate DNS server.
3. **Tip:** Use ` tailscale status --json | jq '.Peer[] | select(.Relay != "") | .HostName' ` to find which peers are relaying through DERP instead of using direct connections. These are your connectivity bottlenecks.
4. **Tip:** WireGuard's ` AllowedIPs ` is both a routing table and an ACL. Setting ` AllowedIPs = 0.0.0.0/0 ` routes ALL traffic through that peer (exit node mode). For split tunneling, only include the specific subnets you need.

- wireguard
- tailscale
- headscale
- mesh-networking
- nat-traversal
- derp
- vpn
- networking

[Back to Blog](<https://lucioduran.com/blog>)
