Why Tailscale Becomes the Wrong Tool
The Coordination Server Is the Hidden Dependency
Tailscale’s WireGuard mesh is elegant, and the client software works well. The problem isn’t the protocol — it’s that the glue holding your mesh together lives on Tailscale’s servers, behind closed-source code you can’t audit or replicate. Every device auth, every key rotation, every peer discovery event goes through login.tailscale.com. If that goes down, your mesh doesn’t degrade gracefully — peers that haven’t cached routes stop resolving each other entirely. For a homelab this is inconvenient. For anything you’ve wired production services through, it’s a real outage you have zero ability to fix.
The DERP relay situation compounds this. WireGuard needs direct UDP paths between peers; when NAT traversal fails (CGNAT, strict firewalls, mobile carriers), Tailscale falls back to its DERP relay servers. You can run your own DERP node, but your traffic still gets routed through Tailscale’s coordination layer to discover which relay to use. You’re not actually air-gapped from their infrastructure — you’ve just moved one hop. Self-hosters running nodes behind carrier-grade NAT will hit DERP relay traffic constantly, and those relays are outside your control, your jurisdiction, and your incident-response loop.
The free tier’s device limit is the friction point that usually forces the decision. The cap isn’t enormous, and once you start counting — workstations, servers, VPS nodes, phones, a Raspberry Pi or two, a NAS — you hit it faster than expected. Bumping to a paid plan to host your own infrastructure feels backwards. The pricing isn’t outrageous, but you’re now paying a recurring fee for a control plane that still isn’t yours, with no self-hosted alternative available at any price tier through Tailscale itself.
Tailscale vs Headscale: I Ran Both for My Private Journaling Setup — Here’s the Honest Breakdown
The air-gap question is where Tailscale fully stops being an option. Regulated environments, isolated home lab segments you want genuinely off the internet, or just paranoia about external dependencies — none of these are solvable with stock Tailscale. The coordination server must be reachable. There’s no offline mode, no bundle-your-own-server path, no “here’s the protocol spec, run it yourself.” Headscale exists as a third-party reimplementation, which is a reasonable answer, but it’s worth being clear that it’s a reverse-engineered compatibility layer, not something Tailscale ships or supports.
The actual operator question is specific: which tool lets you own the full control plane — key exchange, peer discovery, relay infrastructure — without turning network administration into a part-time job? Running raw WireGuard with manual config is the maximalist ownership answer, but you’re writing wg0.conf entries by hand and scripting your own key rotation. The sweet spot is a tool that gives you a self-hosted coordination server with a reasonable operational surface area: one or two containers, a config file you can read, and upgrade paths that don’t require re-architecting your mesh. That’s the gap the alternatives in this piece are trying to fill.
Comparison Table: The Five Alternatives at a Glance
The uncomfortable truth about all five of these tools: you’re not escaping operational complexity, you’re just moving it somewhere you control. Tailscale’s SaaS model absorbs that complexity into their infrastructure. Every option below hands it back to you in the form of a control plane you have to run, upgrade, and back up. That trade is worth making — but go in clear-eyed about what you’re taking on.
| Tool | Control Plane | WireGuard-based | NAT Traversal | Min Server RAM | Biggest Gotcha |
|---|---|---|---|---|---|
| Headscale | Self-hosted | Yes | DERP relay (Tailscale’s protocol, self-hostable) | ~50 MB | Lags behind official Tailscale client feature releases; some client features silently break |
| Netbird | Self-hosted or vendor | Yes | STUN/TURN + ICE (standard WebRTC stack) | ~150 MB (full stack: signal + relay + dashboard) | Self-hosted stack is four separate services; docker-compose drift between versions is real |
| Nebula | Self-hosted (CA + lighthouses) | No (custom UDP overlay) | Lighthouse-assisted hole-punching | ~20 MB | Certificate rotation is fully manual; no built-in revocation workflow |
| ZeroTier (self-hosted controller) | Self-hosted | No (ZeroTier protocol over UDP) | Distributed roots + optional moons | ~80 MB | Moon (custom root) propagation can take several minutes; clients occasionally ignore it |
| innernet | Self-hosted | Yes | None — requires routable server or manual port-forward | ~30 MB | No NAT traversal at all; all peers need path to the innernet server |
The NAT traversal column deserves more attention than it usually gets. Headscale inherits Tailscale’s DERP relay infrastructure — you can self-host DERP servers, but most people don’t, which means they’re still depending on Tailscale’s relay nodes when direct connections fail. Netbird’s ICE/STUN/TURN stack is more operationally familiar if you’ve run WebRTC infrastructure, but TURN relay servers are bandwidth-intensive when connections can’t punch through directly. Nebula and ZeroTier handle traversal at the protocol level without depending on a relay you separately maintain. innernet doesn’t even try — it’s the honest option that skips the magic and requires real network access to the server.
RAM footprints above reflect the server-side control plane only, not the per-node agent. Agent overhead on nodes is modest across all five — typically under 15 MB RSS once the tunnel is established. Where these numbers start to matter is when you’re running the control plane on a small VPS (1 GB RAM, shared) alongside other services. Nebula’s lighthouse at ~20 MB barely registers. Netbird’s full self-hosted stack at ~150 MB means you’ll want it on a dedicated instance or at minimum a 2 GB droplet with swap configured properly.
Operationally, the gotcha column is the one to read twice before committing. Headscale’s lag behind upstream Tailscale client releases has bitten people trying to use newer Tailscale features like tagging or SSH access — the server-side implementation may be absent or broken for weeks after a client ships. Nebula’s manual certificate workflow looks manageable until you have 30 nodes and need to rotate the CA. At that point you’re writing your own tooling. ZeroTier’s moon propagation delays are documented but the client-side behavior — where a client simply keeps routing through public roots even after a moon is configured — requires hands-on debugging to confirm it’s actually working. None of these are dealbreakers, but all of them are costs that don’t show up in the README.
Headscale: Tailscale Clients, Your Control Server
The thing that makes Headscale interesting isn’t that it replaces Tailscale — it’s that it reuses Tailscale. Your nodes run the official tailscale client binary, pointed at your own control server instead of Tailscale’s. Headscale reimplements the coordination plane (key exchange, peer lists, ACL distribution) so the actual WireGuard tunnel setup stays identical to what you’d get from the vendor. That’s a meaningful distinction: you’re not shipping a new VPN stack to every node, you’re just swapping where they phone home.
Getting it running is straightforward. A minimal Docker Compose setup that actually works:
services:
headscale:
image: headscale/headscale:0.23
container_name: headscale
volumes:
- ./config:/etc/headscale
- ./data:/var/lib/headscale
ports:
- "8080:8080"
- "9090:9090" # metrics
command: serve
restart: unless-stopped
The config.yaml has a lot of keys, but only a handful matter for a working deployment:
server_url: https://headscale.yourdomain.com # what clients advertise to each other
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 0.0.0.0:9090
db_type: sqlite
db_path: /var/lib/headscale/db.sqlite
dns_config:
magic_dns: true
base_domain: headnet.internal
nameservers:
- 1.1.1.1
SQLite is fine for anything under a few hundred nodes — don’t reach for Postgres unless you’re actually hitting write contention. Once the server is up, registering a node is one command after running tailscale up --login-server https://headscale.yourdomain.com on the client:
# grab the nodekey from the tailscale up output on the client, then on the headscale host:
headscale nodes register --user myuser --key nodekey:abc123...
MagicDNS works reliably — peers resolve each other by hostname without any additional DNS config on your end. What doesn’t work cleanly is exit node support and anything in Tailscale’s newer feature surface: Tailscale SSH, Funnel, and the App Connector are control-plane features Headscale hasn’t caught up on. The Headscale team maintains a compatibility matrix in the repo, and it’s worth checking it against the specific tailscale client version you’re deploying before you commit. A mismatch between client version and Headscale version tends to manifest as silent registration failures or ACL enforcement behaving unexpectedly rather than clean error messages. The best fit here is a team that already knows Tailscale’s UX and just wants the control plane on metal they own. If you need Tailscale SSH or Funnel, Headscale isn’t a drop-in — those features don’t exist at this layer yet.
Netbird: WireGuard Mesh With a Built-in Admin UI
The part that surprises most people: Netbird’s self-hosted stack is actually three logical components — a signal server, a management server, and a STUN/TURN relay — but they collapse into a single netbird-management Docker image plus a separately deployed coturn instance. You’re not stitching together five repos. The management image handles peer registration, ACL policy distribution, and the web dashboard. The signal server (bundled in the same image) is the lightweight coordination channel that bootstraps ICE negotiation between peers. Coturn is the fallback relay when direct P2P fails, and it’s the one piece you manage separately.
The minimum viable docker-compose.yml block for the management server looks roughly like this:
services:
netbird-management:
image: netbirdio/management:latest
restart: unless-stopped
ports:
- "443:443"
- "33073:33073" # signal gRPC
- "10000:10000" # management gRPC
environment:
- NETBIRD_MGMT_API_ENDPOINT=https://your-domain.example.com
- NETBIRD_SIGNAL_URI=your-domain.example.com:10000
# Leave the OIDC block out entirely if you want setup-key auth
# NETBIRD_OIDC_CONFIGURATION_ENDPOINT=https://your-idp/.well-known/openid-configuration
# NETBIRD_OIDC_CLIENT_ID=your-client-id
volumes:
- ./netbird-mgmt:/var/lib/netbird
Without the OIDC block, Netbird falls back to setup keys — you generate a key in the dashboard, paste it into the client on enrollment, done. For a homelab with five to fifteen nodes this is completely fine and removes an entire dependency (no Keycloak, no Authentik required at setup time). The SSO path is worth it if you’re managing more nodes or want per-user audit trails, but it adds a real operational surface. The OIDC configuration endpoint must be reachable from the management container at startup, not just at login time — that burns people who configure it behind a private DNS that the container can’t resolve.
The NAT traversal story is where Netbird diverges meaningfully from Tailscale. Tailscale uses DERP (a relay protocol they operate) as its fallback; you can self-host a DERP server but most people don’t. Netbird uses ICE — the same Interactive Connectivity Establishment protocol that WebRTC uses — which has better peer-to-peer success rates specifically on symmetric NAT scenarios because it tries more candidate pairs aggressively. The tradeoff: when ICE fails and you fall through to TURN relay, you’re running coturn, which means you’re on the hook for its TLS certs, its UDP port exposure (3478 for STUN, 5349 for TURNS), and its resource cost under relay load. A coturn instance doing no active relay sits around 15 MB RSS. Under heavy relay traffic that number climbs proportionally to throughput, not node count.
On a small VM — a 1 vCPU / 2 GB instance is realistic — the idle footprint for the full stack is around 120 MB RSS total across management, signal, and coturn. That’s comfortable on a 2 GB machine as long as you’re not actively relaying significant traffic. The database backing management is SQLite by default, which is fine up to somewhere in the low hundreds of nodes; past that you’d want to look at the Postgres backend option. One gotcha that doesn’t surface in the quickstart: the management container writes its SQLite file to /var/lib/netbird/, and if you forget to bind-mount that directory, a container restart wipes your entire peer registry. Mount it before you enroll your first node, not after.
Nebula: Flat Mesh Without Any Central Relay
Most mesh VPN tools still phone home to something — a coordination server, a relay, a SaaS dashboard. Nebula doesn’t. The lighthouse is the closest thing to a central component, and all it does is help peers find each other’s public IP and port. Once the handshake completes, the lighthouse is out of the picture entirely. Packets go peer-to-peer, always. That’s a fundamentally different threat model and a fundamentally different ops story than Tailscale’s DERP relay fallback.
The CA setup is genuinely two commands, and that’s not marketing simplification — that’s the actual workflow:
# Generate the CA once, store ca.key somewhere safe (not on any node)
nebula-cert ca -name 'homelab'
# Sign a cert for each node — this is the only auth mechanism
nebula-cert sign -name 'node1' -ip '192.168.100.1/24' -ca-crt ca.crt -ca-key ca.key
Every node gets a config.yaml, its own signed cert, and the CA’s public cert. There’s no token, no API key, no enrollment flow. If the cert is valid and signed by your CA, the node is in. If it’s not, it’s not. The config itself is about 60 lines for a typical node — lighthouse address, cert paths, firewall rules, and whether this node is a lighthouse. A minimal non-lighthouse config looks like this:
pki:
ca: /etc/nebula/ca.crt
cert: /etc/nebula/node1.crt
key: /etc/nebula/node1.key
static_host_map:
"192.168.100.254": ["your-vps-ip:4242"] # lighthouse's nebula IP → public addr
lighthouse:
am_lighthouse: false
hosts:
- "192.168.100.254"
listen:
host: 0.0.0.0
port: 0 # 0 = random ephemeral port, fine for non-lighthouses
firewall:
outbound:
- port: any
proto: any
host: any
inbound:
- port: any
proto: icmp
host: any
What you give up relative to Tailscale is real and worth stating plainly. Nebula has no automatic key rotation — when a cert expires (you set the duration at signing time with -duration 8760h for one year), you re-issue and redeploy manually. There’s no GUI, no mobile app with a split-tunnel toggle, no ACL editor in a browser. The firewall rules live in each node’s YAML. There’s also no concept of exit nodes as a first-class feature. Nebula is a pure infrastructure primitive: it gives you an encrypted overlay network and nothing else. The operational surface is low, but the UX surface is zero.
The sweet spot is a fixed homelab topology — a NAS, a few servers, maybe a VPS, nodes that exist for months or years. Sign the certs with a two-year duration, deploy, and mostly forget it. The worst fit is a dynamic fleet where you’re adding and removing devices frequently and want someone (or something) to handle re-enrollment. Every new node requires you to issue a cert, copy three files to it, and start the daemon. That’s not hard, but it doesn’t scale to a team or a setup where devices join and leave constantly. If your topology changes weekly, the lack of a management plane will grind on you fast.
ZeroTier Self-Hosted Controller and innernet: The Honorable Mentions
The “self-hosted ZeroTier” pitch sounds clean until you actually read the architecture. Running ztncui or posting to the /controller REST API on your own node gives you local network management — peer authorization, route assignment, all of it. But ZeroTier’s planet/moon model still phones home to ZeroTier’s root infrastructure for initial peer discovery unless you explicitly configure a private moon. That step is not prominent in most third-party setup guides. Without it, your “self-hosted” controller still depends on ZeroTier’s uptime for new peers to find each other. Configuring a moon means generating a moon identity, hosting it on a reachable IP, and distributing the moon definition to every peer via zerotier-cli orbit. Doable, but it’s a second project sitting on top of the first one.
# Generate a moon from an existing node's identity
zerotier-idtool initmoon identity.public > moon.json
# Edit moon.json to add your public IP under "stableEndpoints"
zerotier-idtool genmoon moon.json
# Output: 000000DEADBEEF.moon — copy to /var/lib/zerotier-one/moons.d/
# On every peer:
zerotier-cli orbit DEADBEEF DEADBEEF
innernet takes the opposite architectural stance — it’s WireGuard all the way down, with a server component written in Rust that distributes peer configuration as TOML files. The peer invite flow is genuinely nicer than Nebula’s CA ceremony. You run innernet add-peer <network> on the server, hand the resulting invite file to the new peer, and they run innernet install <invite.toml>. No intermediate CA steps, no manually signing certificates. Configuration ends up readable and diffable, which matters when you’re debugging at midnight. The catch is the project’s commit cadence. At the time of writing, the GitHub activity has stretched into multi-month quiet periods. That’s not automatically fatal for stable software, but before you wire innernet into a production homelab, check the commit history yourself — specifically whether issues touching recent kernel versions are getting responses.
The honest framing for both: the technology underneath is sound. ZeroTier’s virtual Ethernet model is mature and the controller API is well-documented. innernet’s WireGuard-native design means the crypto and tunnel primitives aren’t the risk surface. The risk is organizational. Headscale has active maintainers, a clear contribution pipeline, and enough community momentum that a single contributor going quiet doesn’t stall the project. Netbird has a commercial entity behind it with obvious incentive to keep the open-source version functional. ZeroTier self-hosted and innernet carry higher bus-factor exposure — smaller contributor pools, and in innernet’s case, no obvious commercial backing pushing maintenance forward. For a homelab where you can tolerate a slower patch cycle, that trade-off is acceptable. For anything you’d be paged about at 3am, weight that risk seriously before committing.
Picking the Right Tool for Your Setup
The fastest way to narrow this down: ask what you’re actually replacing. If you’re migrating an existing Tailscale deployment — you already have your ACLs defined, your peers are registered, your team knows the tailscale up workflow — Headscale is the obvious move. The client-side experience is identical. You swap the coordination server URL, re-auth your nodes, and you’re done. No new mental model, no relearning. That migration friction is genuinely lower than any other option in this space, and that’s not a small thing when you’re managing more than a handful of nodes.
If you need a graphical management interface and real ACL tooling without hand-editing YAML for every peer relationship, Netbird is where you land. The UI is well-built, the policy model maps cleanly to how most people think about network segmentation, and you can self-host the full control plane. The honest cost is that you’re now also operating coturn as infrastructure — that’s your STUN/TURN relay, and it needs to stay up for peers behind symmetric NAT to stay connected. Plan for that. It’s not hard to run, but it’s another service to monitor, another port to keep open (3478/udp and 5349/tcp by default), and another thing that pages you at 2am if it silently dies.
For a static mesh where you know your topology won’t change much and you want the smallest possible attack surface: Nebula. There’s no control plane running anywhere. No API endpoint accepting connections. No coordination server to patch when a CVE drops. You generate certs with nebula-cert sign, distribute them out-of-band, point peers at your lighthouse IPs, and the network runs. The lighthouse is just a rendezvous point — it holds no state about your traffic. If your threat model includes “an attacker who can reach my control plane,” Nebula removes that surface entirely. The tradeoff is that adding a new peer is a manual operation, every time.
The same evaluation logic extends cleanly to AI workloads and automation pipelines that need to run behind a mesh — whether that’s an n8n instance talking to a local Ollama endpoint, or a semantic search service you don’t want publicly exposed. For those setups, the local-vs-cloud tradeoff question doesn’t stop at the model layer; it runs all the way down to the networking layer. The same reasoning that pushes you toward local models for data sensitivity reasons also pushes you toward self-hosted mesh networking. If you’re working through that broader tradeoff, the AI Coding Tools in 2026: Cloud Copilots vs Local Models guide covers how these decisions stack across the toolchain — the criteria transfer directly.