All posts

WireGuard vs Tailscale vs Headscale on a VPS

Tailscale 1.104.4 on Headscale 0.29.7. Direct iperf put 66 bytes on the hub. Kernel WireGuard through it: 722 Mbps. The relay: 108.8 Mbps.

Written by NodeRings Inc.

NodeRings Inc. writes these notes. The company runs the marketplace and the agent that stays on the provider’s side of the hypervisor.

A 10 second iperf delivered 1,033,240,576 bytes from one guest to the other. The hub's NIC moved one packet in and one packet out, 66 bytes each way. Those two guests were running Tailscale 1.104.4, and the coordination server on the hub was Headscale 0.29.7. The path was direct: tailscale ping answered via 192.0.2.12:41641 in 1ms.

The same pair, with UDP to each other dropped so the path had to be the hub's relay, delivered 170,655,744 bytes in 10 seconds. The hub NIC moved 211,231,726 bytes in and 205,567,209 bytes out. tailscale ping answered via DERP(headscale), eight times, at 1 ms or 2 ms.

Kernel WireGuard, where the hub was the only peer, is the third shape. A 10 second iperf delivered 1,080,164,352 bytes, and the hub NIC moved 1,188,825,276 bytes in and 1,188,470,260 bytes out. The data plane is WireGuard in all three. The hub is on the path only when you put it there, or when the direct UDP fails and the relay is the one you are hosting.

Three drawings: both clients peer only with a WireGuard hub, clients connect directly while a coordination server sits off to the side, and the same direct path with the coordination server and the relay on the VPS.
Left, the hub forwards every byte. The direct UDP in the other two columns is the 626 Mbps run. The relay in the measurements is the VPS box. Tailscale's public DERP is drawn as their fallback, and this lab left it out of the map.

Three guests, one core each

Each guest was Ubuntu 24.04.5 LTS, kernel 6.8.0-147-generic, qemu -smp 1 -m 1024, KVM. free reported 961 MB. lscpu reported one core of an Intel Core i7-1185G7E. The lab NIC was 192.0.2.10 on the hub, .11 on client-a, .12 on client-b. Packages on the hub: wireguard and wireguard-tools 1.0.20210914-1ubuntu4, iperf3 3.16-1build2, headscale 0.29.7. wg --version printed wireguard-tools v1.0.20210914. The clients' tailscale version printed 1.104.4.

An unencrypted iperf across the bridge, three passes of 10 seconds, read 29,338, 27,438, and 22,795 Mbps at the receiver. That is virtio between guests on one machine, the ceiling these cores had with the tunnel off. A provider port would be a different measurement.

iperf below is TCP, one stream, iperf3 -t 30, and the number is sum_received.bits_per_second. Three passes. The 10 second runs above are the byte counters, and they ran faster than the 30 second set: 862.3 Mbps on the kernel hub path, 824.7 Mbps direct, 136.5 Mbps through the relay.

Path30 s passes, MbpsMedian
Kernel WireGuard, via the hub739.0, 722.1, 696.4722.1
tailscaled, direct UDP626.2, 627.1, 619.8626.2
tailscaled, DERP on the hub113.5, 103.1, 108.8108.8

Ping, after the tunnel was up. The underlay, 30 packets to 192.0.2.12, averaged 0.451 ms (min 0.381, max 0.515). Kernel WireGuard, 30 packets to 10.8.0.3, averaged 1.115 ms (min 0.860, max 1.333). Direct tailscaled, 20 packets to 100.64.0.2, averaged 1.097 ms (min 0.648, max 2.996). The relay, 20 packets, averaged 1.864 ms (min 1.477, max 3.970). All four averages are guests on one host. A relay in another city would add that city's delay on top of the 1.864 ms. The iperf stayed on these guests. Tailscale's public DERP regions were left out of the map.

Bars for three 30 second iperf medians: kernel WireGuard via the hub at 722.1 Mbps, tailscaled direct at 626.2 Mbps, and the Headscale relay at 108.8 Mbps. RSS beside them: headscale 51676 kB, tailscaled 43336 kB.
Receiver Mbps, median of three 30 second passes. RSS is the idle ps figure after the direct run. wg-quick exits, and the kernel module keeps the tunnel.

The hub path is kernel WireGuard

wg0 on the hub was 10.8.0.1/24, listen port 51820. client-a was 10.8.0.2/32, client-b was 10.8.0.3/32. Each client had AllowedIPs = 10.8.0.0/24, Endpoint = 192.0.2.10:51820, and PersistentKeepalive = 25. From the first key file to a ping of 10.8.0.3 was 10.2 seconds. The keepalive is there because a stateful firewall forgets a UDP session; Tailscale's NAT traversal post uses 30 seconds as a common UDP timeout. 25 seconds is inside that.

The hub's PostUp turned on net.ipv4.ip_forward and masqueraded traffic leaving enp0s2, the NIC that held the default route. The iperf stayed inside 10.8.0.0/24 and was forwarded on wg0. The masquerade rule matches enp0s2 only. On a VPS, that oifname is the public NIC. WireGuard on a VPS, with a phone QR is the same interface with a public endpoint and one peer. Vultr's Ubuntu 24.04 guide is the long form of the install. This guest used the archive package. wg-easy is a container in front of the same kind of file. The process here was wg-quick@wg0.

wg show after the 10 second transfer: the hub had sent 1.07 GiB to client-b and received 1.07 GiB from client-a, with 4.72 MiB the other way, which is the TCP acks. Add the NIC's in and out and divide by the 1,080,164,352 payload bytes and the factor is 2.20. One gigabyte of payload, taking a gigabyte as 1,000,000,000 bytes, is 2.20 GB on the hub NIC. Seven nightly 1 GB files are 15.4 GB on that NIC. Ten such flows, if each one hairpins the way this pair did, are 154 GB. That last step is multiplication. I ran one pair.

Tailscale in 2026 is a control plane you rent

The client on Linux is wireguard-go behind a tun device. ip -d link show tailscale0 printed tun and mtu 1280. The kernel module was the WireGuard path above. This one was userspace, which is the split in Tailscale's how it works post: the coordination server exchanges public keys and policy, and the payload is the WireGuard tunnel between the nodes. Their DERP servers, Designated Encrypted Relay for Packets, forward traffic that is already encrypted. The private key stays on the node that generated it, so the relay forwards ciphertext.

On 8 April 2026 the changelog set the Personal plan at six free users. The line before that was three. The same entry says every plan can have an unlimited number of user devices, new signups are billed on occupied seats, and an existing tailnet on a legacy plan stays on monthly active users. After four hours in the tailnet, an ephemeral node is treated as a tagged device and stops drawing ephemeral minutes. The pricing page is the current table.

PlanPriceUsersACL groupsEphemeral minutes
Personal$0631,000
Standard$8 per user, per monthunlimited101,000
Premium$18 per user, per monthunlimited30010,000

Personal also says unlimited user devices and 50 tagged resources to start. Standard and Premium say "everything in" the plan below. The lines I read for those two were the user cap, the ACL groups, and the ephemeral minutes. Funnel, their way to publish a local port, stayed off.

The binary is theirs. The server it logged into was Headscale on the hub. A direct path in their cloud would still be this client. The relay, in that setup, would be a DERP region of theirs. Here the relay process was the one on the hub.

Headscale is that server, on the VPS

Headscale 0.29.7 listened on 0.0.0.0:443 with a certificate for headscale.lab, and on UDP 3478 for STUN. The log line at start said clients below v1.80 are rejected. The 0.29.7 notes also say the Tailscale SSH fixes land in client 1.104.4, which is the client that joined. The DERP map urls list was empty, so the only relay on offer was the embedded one, region code headscale, DERP port 443, at 192.0.2.10. A default config ships Tailscale's public DERP map. With that list left in, a blocked UDP path would leave the VPS and come back from their relay. This run took the other option.

headscale users create lab
headscale preauthkeys create --user 1 --reusable --expiration 2h

--user wants the numeric id. headscale users list showed lab as id 1. The two clients:

curl -fsSL https://tailscale.com/install.sh | sh
tailscale up --login-server https://headscale.lab:443 \
  --auth-key YOUR-KEY --hostname client-a --accept-dns=false

From the start of those installs to both nodes answering tailscale up was 28.4 seconds. Headscale was already up. The server also assigned fd7a:115c:a1e0::1 and fd7a:115c:a1e0::2. The iperf used the IPv4 addresses. --accept-dns=false left the guest resolver on the lab NIC. The server config still had MagicDNS on, with base_domain: tailnet.example.com. The policy file was one accept rule for every source and every destination. The state file is /var/lib/headscale/db.sqlite.

Idle ps RSS after the direct run: headscale 51,676 kB, tailscaled on client-a 43,336 kB, tailscaled on client-b 42,624 kB. The guest had 961 MB, and free showed about 296 MB used. GetVPS walks the install as a service you host. The deb is what this guest ran.

headscale nodes list reduced to id, hostname, user, IPv4 address, and online, for client-a at 100.64.0.1 and client-b at 100.64.0.2.
Both nodes online, user lab. The command also prints machine keys and a last-seen clock. Those columns are left off this figure.
tailscale status with client-a and client-b, then a direct ping via 192.0.2.12 port 41641 in 1 ms, and a relay ping via DERP(headscale).
The same two nodes. The first ping is the direct UDP path. The second is after UDP between the clients was dropped.

For the relay measurement, each client dropped UDP on the lab NIC except packets to 192.0.2.10, and dropped UDP on the NIC with the default route. STUN to the hub still worked. UDP between .11 and .12 was dropped. tailscale status then read active; relay "headscale".

The relay factor is 2.44 NIC bytes, in plus out, per payload byte. One gigabyte of payload is 2.44 GB on the hub NIC. Seven nightly 1 GB files are 17.1 GB. Ten such flows are 171 GB, again as multiplication of this one pair. The direct 10 second transfer, 1,033,240,576 bytes, stayed off that ratio: the hub counted one 66 byte packet each way.

Which one you are actually choosing

One device to one VPS, and you want the bytes to cross that VPS, is kernel WireGuard. The hub here forwarded the payload, and wg-quick was up in 10.2 seconds on keys the guest generated.

Several people, each with their own machines, and Tailscale holding the keys and the public relay is acceptable, is the Personal plan. Six users, unlimited devices, and in this lab the direct path left the hub at 66 bytes. When that path fails, their relay carries the bytes and their account carries the egress.

The same client, with the keys and the relay on a machine you patch, is Headscale. Budget the 51,676 kB, the SQLite file, and the 2.44 factor for whatever share of the traffic fails the direct path. A phone QR for plain WireGuard is the other post. Publishing a service on a public name is Cloudflare Tunnel, a VPS proxy, or Pangolin.

The guest firewall that was loaded for the whole run is below. Policy drop, then established traffic, ICMP echo, TCP 22, UDP 51820, TCP 443, and UDP 3478. The iperf and the handshakes passed through it. On a Proxmox VM from NodeRings, the ruleset in front of the guest is the firewall guide. This figure is nftables inside the guest.

nftables input chain with policy drop, accepting established traffic, ICMP echo, TCP 22, UDP 51820, TCP 443, and UDP 3478.
The hub's input chain while the three paths were measured. 51820 is WireGuard. 443 and 3478 are Headscale and its STUN.

Commands

The client peer. The hub's matching peer block has this public key and AllowedIPs = 10.8.0.2/32.

[Interface]
Address = 10.8.0.2/32
PrivateKey = <client key>

[Peer]
PublicKey = <hub key>
Endpoint = 192.0.2.10:51820
AllowedIPs = 10.8.0.0/24
PersistentKeepalive = 25

The hub's PostUp, with enp0s2 renamed to the NIC that has the default route:

sysctl -w net.ipv4.ip_forward=1
nft add table ip wgmasq
nft 'add chain ip wgmasq postrouting { type nat hook postrouting priority 100; policy accept; }'
nft add rule ip wgmasq postrouting oifname "enp0s2" masquerade

The 30 second passes were iperf3 -c 10.8.0.3 -t 30 and iperf3 -c 100.64.0.2 -t 30, with iperf3 -s on the far guest.

FAQ

Does Tailscale see the traffic?

Their how it works post puts key exchange and policy on the coordination server, and the payload in the WireGuard tunnel. A DERP server forwards packets that are already encrypted, and the private key stays on the node. This lab's coordination server and relay were Headscale on the hub. The company's control plane stayed out of the path.

Do the official apps work with Headscale?

The Linux client 1.104.4 joined with --login-server https://headscale.lab:443 and a preauth key. That is the client this post measured. Phone and desktop builds are a separate install, and this lab stopped at Linux.

What about an exit node on the VPS?

An exit node advertises a default route. Traffic then leaves from that node's NIC, and on a VPS those bytes are egress. The tailscale up in this run carried the login server, the auth key, the hostname, and --accept-dns=false. The relay numbers are the other case: the VPS moves the packets because it is the relay, and each client's own default route stays where it was.

Is this faster than OpenVPN?

OpenVPN was outside this run. The two data planes measured here were the kernel WireGuard module, at a 722.1 Mbps median through the hub, and tailscaled's tun device, at 626.2 Mbps direct and 108.8 Mbps through the relay on the same guests.