How to Install Headscale on Ubuntu 24.04 VPS — Self-Hosted Tailscale Coordination Server
Headscale is an open-source, self-hosted implementation of the Tailscale control server. It lets you run a fully private mesh VPN built on WireGuard without relying on Tailscale's SaaS coordination layer. This tutorial walks you through installing Headscale on an Ubuntu 24.04 VPS, configuring TLS, wiring up pre-auth keys, writing ACLs, deploying headscale-ui, configuring an embedded DERP relay, and fronting the whole thing with Nginx for HTTPS and gRPC.
Want a private WireGuard mesh without Tailscale's per-user pricing? Deploy Headscale on a CloudCore Starter VPS for EUR 7.99/month and coordinate unlimited nodes across your infrastructure.
Table of Contents
What is Headscale?
Headscale is an open-source re-implementation of Tailscale's coordination server, written in Go and maintained primarily by Juan Font. It speaks the same protocol as the hosted Tailscale control plane, which means the official Tailscale clients on Linux, macOS, Windows, iOS, and Android connect to it without any modification. The source lives on GitHub at juanfont/headscale under the BSD-3-Clause license.
Under the hood Headscale is a coordination and key-exchange service. WireGuard itself does the actual encrypted tunnel work on each node; Headscale's job is to hold the node registry, distribute public keys, publish the network map (which peers exist, which IPs they have, which routes they advertise), enforce access control, and optionally relay traffic through a DERP server when direct peer-to-peer NAT traversal fails. No user traffic is routed through Headscale in the common case — it is a control plane, not a data plane.
Headscale supports the features most users actually rely on from Tailscale: pre-auth keys for unattended node enrollment, OIDC single sign-on (Keycloak, Authentik, Google, GitHub), subnet routing so one node can advertise a CIDR from an on-premise LAN, exit nodes to route all client traffic through a chosen peer, MagicDNS with .tailnet resolution, tagged nodes for role-based ACLs, and HuJSON ACLs that are byte-compatible with Tailscale's own policy format. Features currently not implemented include Tailscale SSH, Funnel (public ingress), and Taildrop.
Why Self-Host a Coordination Server?
Running your own Headscale server alongside the WireGuard clients gives you control and economics that the hosted service cannot match:
- No per-user pricing -- Tailscale charges per user and caps devices per plan on its free tier. A Headscale VPS costs a flat EUR 7.99/month regardless of whether you have three nodes or three hundred.
- Full data sovereignty -- Your node registry, key material, and ACL policy stay on your server in an EU jurisdiction of your choice. Nothing is shared with a third-party SaaS.
- Custom ACL logic -- You can iterate on the ACL file, reload it live, and integrate policy changes into your own CI/CD pipeline. No waiting on upstream UI changes.
- Own your DERP -- Running your own DERP relay means fallback NAT-traversal traffic stays inside your infrastructure and on a VPS with known bandwidth, not on whichever Tailscale-operated relay is closest.
- OIDC freedom -- Headscale integrates with any OIDC provider. Plug in your existing Keycloak, Authentik, or Zitadel instance instead of creating yet another identity silo.
- No vendor lock-in -- Headscale uses a Postgres or SQLite database you control. Migration or backup is a file copy, not an export ticket.
- Air-gapped deployments -- Headscale can run entirely on a private network with no outbound internet, coordinating nodes in isolated lab or industrial environments.
Headscale vs. Tailscale SaaS vs. NetBird
Picking the right mesh VPN depends on how much operational surface you want to own. Here is an honest comparison of the three most common options.
| Feature | Headscale (self-hosted) | Tailscale SaaS | NetBird (self-hosted) |
|---|---|---|---|
| Underlying protocol | WireGuard | WireGuard | WireGuard |
| Coordination server | You run it | Tailscale-hosted | You run it (or SaaS tier) |
| Monthly cost (20 nodes) | EUR 7.99 (VPS) | ~USD 60-120 (user-based) | EUR 7.99 (VPS) or USD 0-9/user |
| Per-user fees | None | Yes, above free tier | Free self-hosted, paid SaaS |
| ACL format | HuJSON (Tailscale-compatible) | HuJSON | JSON policy |
| OIDC SSO | Yes | Yes | Yes |
| Web UI | Community (headscale-ui) | Official | Official |
| Tailscale SSH | Not yet | Yes | N/A |
| Funnel (public ingress) | Not implemented | Yes | Via peer routing |
| DERP relay | Embedded or external | Tailscale-operated | TURN via Coturn |
| Mobile client | Official Tailscale apps | Official Tailscale apps | Official NetBird apps |
| Data residency | Your VPS | USA (Tailscale) | Your VPS or EU SaaS |
| Best for | Teams who already like Tailscale clients and want to drop the SaaS bill | Small teams who value zero ops | Teams who want a polished built-in UI and don't mind running the full stack |
Headscale wins specifically when you already trust the Tailscale client ecosystem (mobile apps, Magic DNS, exit nodes) and just want to remove the hosted control plane from the equation.
Prerequisites
Before starting, make sure you have:
- A VPS running Ubuntu 24.04 LTS with root or sudo access.
- SSH access to the server.
- A public domain name pointed at the server's IPv4 (and ideally IPv6) address. Headscale requires a real TLS certificate — clients will not accept self-signed certs in production. In this guide we use
headscale.example.com. - Ports 80 and 443 open on the firewall and reachable from the public internet, required for Let's Encrypt issuance and client connections.
- Port 3478/udp open if you plan to run the embedded DERP relay (STUN probe).
- At least 2 GB of RAM for up to a few hundred nodes; 4 GB is comfortable for production.
Recommended Plan: CloudCore Starter>
The Headscale daemon itself is extremely lightweight. Memory usage stays under 100 MB even with hundreds of nodes connected, and the SQLite database is a few MB. For the vast majority of deployments, the CloudCore Starter plan is the right fit:>
- 2 vCPU cores
- 4 GB RAM
- 50 GB NVMe SSD
- Unmetered bandwidth
- EUR 7.99/month>
For larger deployments (1,000+ nodes) or heavy DERP relay traffic, step up to CloudCore Professional at EUR 19.99/month (6 vCPU, 12 GB RAM, 100 GB NVMe) or CloudCore Business at EUR 29.99+/month (dedicated cores, 16-32 GB RAM).
Connect to your server:
ssh root@your-server-ipStep 1: Update System Packages
Bring the system up to date before installing new services:
sudo apt update && sudo apt upgrade -yInstall the utilities we will need for the rest of the guide:
sudo apt install -y curl wget gnupg ca-certificates ufwIf the kernel was updated, reboot and reconnect:
sudo rebootSet a hostname that matches the domain you will use (optional but clean):
sudo hostnamectl set-hostname headscaleStep 2: Install Headscale from the .deb Package
The Headscale project publishes official .deb packages on its GitHub releases page, which is the simplest path on Ubuntu. Grab the latest stable release for amd64:
HEADSCALE_VERSION="0.26.1"
wget -O /tmp/headscale.deb \
"https://github.com/juanfont/headscale/releases/download/v${HEADSCALE_VERSION}/headscale_${HEADSCALE_VERSION}_linux_amd64.deb"Install the package:
sudo dpkg -i /tmp/headscale.debExpected output:
Selecting previously unselected package headscale.
(Reading database ... 63214 files and directories currently installed.)
Preparing to unpack /tmp/headscale.deb ...
Unpacking headscale (0.26.1) ...
Setting up headscale (0.26.1) ...
Created symlink /etc/systemd/system/multi-user.target.wants/headscale.service → /lib/systemd/system/headscale.service.The package performs four actions:
headscale binary to /usr/bin/headscale.headscale that owns the service./etc/headscale/ for configuration and /var/lib/headscale/ for state./lib/systemd/system/headscale.service but does not start it — we want to configure first.Verify the binary:
headscale versionExpected output:
0.26.1Step 3: Configure config.yaml
Headscale reads its configuration from /etc/headscale/config.yaml. The package installs a template; replace it with a production-ready version.
Back up the template:
sudo cp /etc/headscale/config.yaml /etc/headscale/config.yaml.distCreate the new configuration:
sudo tee /etc/headscale/config.yaml > /dev/null <<'EOF'
The public URL clients will use to reach your control server.
Must be HTTPS in production.
server_url: https://headscale.example.comAddress Headscale binds to locally. Nginx will forward to this.
listen_addr: 127.0.0.1:8080Metrics endpoint (Prometheus-compatible). Bound to localhost only.
metrics_listen_addr: 127.0.0.1:9090gRPC listener, used by the headscale CLI for remote admin.
grpc_listen_addr: 127.0.0.1:50443
grpc_allow_insecure: falsePrivate key used to sign node registrations.
private_key_path: /var/lib/headscale/private.keyNoise protocol key, required by Tailscale v2 clients.
noise:
private_key_path: /var/lib/headscale/noise_private.keyThe CIDR ranges assigned to nodes on the tailnet.
prefixes:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48Database backend. SQLite is fine for most deployments up to ~1k nodes.
database:
type: sqlite
sqlite:
path: /var/lib/headscale/db.sqliteFor larger deployments, switch to Postgres:
type: postgres
postgres:
host: 127.0.0.1
port: 5432
name: headscale
user: headscale
pass: changeme
DERP configuration — we define our embedded relay in Step 8.
derp:
server:
enabled: true
region_id: 999
region_code: "local"
region_name: "Self-Hosted DERP"
stun_listen_addr: "0.0.0.0:3478"
urls:
- https://controlplane.tailscale.com/derpmap/default
auto_update_enabled: true
update_frequency: 24hEphemeral node inactivity timeout.
ephemeral_node_inactivity_timeout: 30mHow long before an unused pre-auth key expires.
node_update_check_interval: 10sLog level: trace | debug | info | warn | error
log:
level: info
format: textDNS settings pushed to all clients.
dns:
magic_dns: true
base_domain: tailnet.example.com
nameservers:
global:
- 1.1.1.1
- 9.9.9.9
search_domains: []ACL policy file (created in Step 7).
policy:
mode: file
path: /etc/headscale/acl.hujsonAddress used by clients that fall back to a DERP relay.
unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"
EOFA few important notes on the fields:
server_urlmust match exactly what clients will use intailscale up --login-server=.... Get this wrong and clients silently reject TLS certificates.listen_addr: 127.0.0.1:8080binds Headscale to localhost only. Nginx will terminate TLS and forward traffic in Step 9.grpc_listen_addr: 127.0.0.1:50443is the admin CLI endpoint. Nginx will also proxy this over HTTPS on port 443 so thatheadscaleCLI calls from your laptop work.prefixes.v4: 100.64.0.0/10is the RFC 6598 carrier-grade NAT range Tailscale/Headscale use for overlay IPs. Do not change unless you know why.dns.base_domainmust be a domain different fromserver_url. Hereserver_urlisheadscale.example.comand the tailnet MagicDNS suffix istailnet.example.com.
sudo chown -R headscale:headscale /etc/headscale /var/lib/headscale
sudo chmod 600 /etc/headscale/config.yamlCreate the socket directory:
sudo mkdir -p /var/run/headscale
sudo chown headscale:headscale /var/run/headscaleStep 4: Create the systemd Service
The .deb installed a systemd unit, but we want to confirm its behaviour and add a tmpfiles rule so /var/run/headscale survives reboots.
Inspect the installed service:
cat /lib/systemd/system/headscale.serviceCreate the tmpfiles rule so the socket directory is recreated on every boot:
sudo tee /etc/tmpfiles.d/headscale.conf > /dev/null <<EOF
d /var/run/headscale 0770 headscale headscale -
EOFApply it:
sudo systemd-tmpfiles --createEnable and start the service:
sudo systemctl daemon-reload
sudo systemctl enable --now headscaleCheck status:
sudo systemctl status headscaleExpected output:
● headscale.service - headscale coordination server for Tailscale
Loaded: loaded (/lib/systemd/system/headscale.service; enabled; preset: enabled)
Active: active (running) since Thu 2026-04-16 09:00:00 UTC; 3s ago
Main PID: 1842 (headscale)
Tasks: 8 (limit: 4617)
Memory: 38.6M
CPU: 156ms
CGroup: /system.slice/headscale.service
└─1842 /usr/bin/headscale serveFollow logs to confirm it booted cleanly:
sudo journalctl -u headscale -n 40 --no-pagerYou should see lines like listening and serving HTTP on 127.0.0.1:8080 and DERP server started on :3478.
Step 5: Create a User and Pre-Auth Key
Headscale organises nodes under users (analogous to Tailscale's user identity). Create one for your first batch of machines:
sudo headscale users create opsExpected output:
User created
ID | Name | Created
1 | ops | 2026-04-16 09:05:00List users:
sudo headscale users listPre-auth keys let you enroll a node in one non-interactive step, which is how we will register servers, CI runners, and Kubernetes pods. Generate a reusable, ephemeral key valid for 24 hours:
sudo headscale preauthkeys create --user ops --reusable --expiration 24hExpected output:
ced26c...truncated...a6b3fVariant keys you may also want:
- One-shot key (single node, can't be reused):
sudo headscale preauthkeys create --user ops --expiration 1h- Ephemeral key (node auto-removed when it disconnects, ideal for CI):
sudo headscale preauthkeys create --user ops --ephemeral --expiration 24h- Tagged key (key bakes in ACL tags — see Step 7):
sudo headscale preauthkeys create --user ops --reusable --expiration 24h \
--tags tag:server,tag:prodList keys:
sudo headscale preauthkeys list --user opsStep 6: Register Tailscale Clients
Before proceeding, complete Step 9 (Nginx + TLS) on the Headscale server so that https://headscale.example.com resolves and serves a valid certificate. Then return to this step to enroll nodes.
Linux Node
Install Tailscale using the official package repo:
curl -fsSL https://tailscale.com/install.sh | shEnroll the node against your Headscale server with the pre-auth key:
sudo tailscale up \
--login-server=https://headscale.example.com \
--authkey=ced26c...your-key-here...a6b3f \
--accept-routes \
--accept-dnsExpected output:
Success.Verify the node is registered from the Headscale server:
sudo headscale nodes listExpected output:
ID | Hostname | Name | MachineKey | NodeKey | User | IP addresses | Ephemeral | Last seen | Online
1 | web-01 | web-01 | [abc...] | [def...] | ops | 100.64.0.1, fd7a:115c:... | false | 2026-04-16 09:20:00 | onlinemacOS / Windows Node
The Tailscale Mac and Windows apps accept a custom login server but it is buried in the settings. A cleaner approach is to set the login server via CLI after installing the app:
macOS (with the CLI enabled):
tailscale login --login-server=https://headscale.example.comWindows (PowerShell, requires the open-source Tailscale client or tailscale.exe on PATH):
tailscale login --login-server=https://headscale.example.comBoth platforms will open a browser window showing a headscale registration command. Copy the device key from the URL and run on the Headscale server:
sudo headscale nodes register --user ops --key nodekey:abc123...Mobile
The official Tailscale iOS and Android apps require three taps from the login screen to enter a custom coordination server URL. Open the app, tap the settings gear, tap "Accounts", tap "Use custom coordination server", and enter https://headscale.example.com.
Step 7: Write and Apply ACLs
Headscale uses Tailscale's HuJSON ACL format. Create an initial policy that defines tags, groups, and explicit allow rules:
sudo tee /etc/headscale/acl.hujson > /dev/null <<'EOF' { // Groups gather users under a label. "groups": { "group:admins": ["[email protected]", "[email protected]"], "group:devs": ["[email protected]"] },// Tag owners declare which users may apply each tag to a node. "tagOwners": { "tag:server": ["group:admins"], "tag:prod": ["group:admins"], "tag:ci": ["group:admins"], "tag:dev": ["group:admins", "group:devs"] },
// ACLs are evaluated top-to-bottom. First match wins. "acls": [ // Admins have unrestricted access to everything. { "action": "accept", "src": ["group:admins"], "dst": [":"] },
// Devs can reach dev-tagged nodes on any port. { "action": "accept", "src": ["group:devs"], "dst": ["tag:dev:*"] },
// CI runners can reach prod nodes only on SSH and HTTPS. { "action": "accept", "src": ["tag:ci"], "dst": ["tag:prod:22,443"] },
// All tagged servers can talk to each other on any port // (internal service-to-service traffic). { "action": "accept", "src": ["tag:server"], "dst": ["tag:server:*"] } ],
// SSH rules (informational — Tailscale SSH is not enforced by Headscale yet). "ssh": [ { "action": "accept", "src": ["group:admins"], "dst": ["tag:server"], "users": ["root", "ubuntu"] } ] } EOF
Apply and validate:
sudo chown headscale:headscale /etc/headscale/acl.hujson
sudo headscale policy check --file /etc/headscale/acl.hujsonExpected output:
Policy is validReload the policy live (no service restart required):
sudo headscale policy set --file /etc/headscale/acl.hujsonApply tags to an existing node:
sudo headscale nodes tag -i 1 -t tag:server,tag:prodStep 8: Configure the Embedded DERP Relay
DERP (Designated Encrypted Relay for Packets) is Tailscale's fallback when two peers cannot establish a direct UDP connection through NAT. Headscale can point clients at Tailscale's public DERP mesh (which the urls list in config.yaml does by default), run its own embedded DERP server, or both.
The derp.server block we added in Step 3 already enabled the embedded relay on UDP port 3478 (STUN) and the HTTPS listener (on the same port as Headscale itself). Open the firewall:
sudo ufw allow 3478/udp comment 'DERP STUN'
sudo ufw allow 80/tcp comment 'HTTP (Let''s Encrypt)'
sudo ufw allow 443/tcp comment 'HTTPS (Headscale + DERP)'
sudo ufw allow 22/tcp comment 'SSH'
sudo ufw enableConfirm the relay is reachable:
curl -s https://headscale.example.com/derp/probeExpected output:
DERP server is runningFrom any client, inspect the DERP status:
tailscale netcheckExpected output (abbreviated):
Report:
* Your DERP region: 999 (Self-Hosted DERP)
* DERP latency:
- 999: 12ms (Self-Hosted DERP)If you want to disable the public Tailscale DERP mesh entirely and use only your own relay, remove the derp.urls list from config.yaml and reload:
derp:
server:
enabled: true
region_id: 999
...
# urls: [] # no external DERP
auto_update_enabled: falseRestart:
sudo systemctl restart headscaleStep 9: Nginx Reverse Proxy for HTTPS and gRPC
Headscale does not terminate TLS itself in production deployments — Nginx (or Caddy) handles that, then forwards to the local listener. We also need Nginx to forward gRPC on a separate HTTPS vhost so that the headscale CLI can be used remotely.
Install Nginx and Certbot:
sudo apt install -y nginx certbot python3-certbot-nginxCreate the main HTTPS vhost for the coordination server:
sudo tee /etc/nginx/sites-available/headscale > /dev/null <<'EOF'HTTP -> HTTPS redirect.
server { listen 80; listen [::]:80; server_name headscale.example.com;location /.well-known/acme-challenge/ { root /var/www/html; } location / { return 301 https://$host$request_uri; } }
Main coordination server (Tailscale client traffic).
server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name headscale.example.com;ssl_certificate /etc/letsencrypt/live/headscale.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/headscale.example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; add_header X-Content-Type-Options nosniff;
# Clients hold long-polling connections open. Raise timeouts. keepalive_timeout 900s; client_max_body_size 50m;
location / { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme;
# WebSockets are used for noise streaming. proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";
proxy_buffering off; proxy_read_timeout 900s; proxy_send_timeout 900s; } } EOF
Create a second vhost for the gRPC admin endpoint on a different hostname (headscale-grpc.example.com):
sudo tee /etc/nginx/sites-available/headscale-grpc > /dev/null <<'EOF' server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name headscale-grpc.example.com;ssl_certificate /etc/letsencrypt/live/headscale-grpc.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/headscale-grpc.example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3;
# gRPC requires HTTP/2 end to end. location / { grpc_pass grpcs://127.0.0.1:50443; grpc_read_timeout 900s; grpc_send_timeout 900s;
error_page 502 = /error502grpc; }
location = /error502grpc { internal; default_type application/grpc; add_header grpc-status 14; add_header content-length 0; return 204; } } EOF
Note the use of grpc_pass grpcs://.... Headscale's gRPC listener is TLS itself when grpc_allow_insecure: false — which means Nginx must terminate the public HTTPS and then re-encrypt to the local listener. For simplicity you can set grpc_allow_insecure: true in config.yaml (since it is bound to 127.0.0.1) and switch to grpc_pass grpc://... without TLS on the backhaul.
Enable both sites:
sudo ln -s /etc/nginx/sites-available/headscale /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/headscale-grpc /etc/nginx/sites-enabled/
sudo nginx -tIssue certificates:
sudo certbot --nginx \
-d headscale.example.com \
-d headscale-grpc.example.com \
--non-interactive --agree-tos --email [email protected]Reload:
sudo systemctl reload nginxVerify end-to-end from your laptop:
curl -v https://headscale.example.com/healthExpected output:
{"status":"pass"}Use the CLI remotely via the gRPC endpoint. On your laptop, install the headscale binary and create ~/.config/headscale/config.yaml:
cli:
address: headscale-grpc.example.com:443
api_key: <generate with: headscale apikeys create>
insecure: falseGenerate and copy the API key on the server:
sudo headscale apikeys create --expiration 90dPaste the returned key into the laptop config. Test:
headscale nodes listStep 10: Deploy headscale-ui
Headscale ships without a web UI — admin is CLI-first. The community project headscale-ui provides a lightweight React frontend that talks to the Headscale gRPC API.
Run it in Docker alongside Headscale:
sudo apt install -y docker.io sudo systemctl enable --now docker
sudo docker run -d \ --name headscale-ui \ --restart unless-stopped \ -p 127.0.0.1:8443:443 \ ghcr.io/gurucomputing/headscale-ui:latest
Add a third Nginx vhost to serve the UI on ui.headscale.example.com:
sudo tee /etc/nginx/sites-available/headscale-ui > /dev/null <<'EOF' server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name ui.headscale.example.com;ssl_certificate /etc/letsencrypt/live/ui.headscale.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/ui.headscale.example.com/privkey.pem;
location / { proxy_pass https://127.0.0.1:8443; proxy_ssl_verify off; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; } } EOF
sudo ln -s /etc/nginx/sites-available/headscale-ui /etc/nginx/sites-enabled/ sudo certbot --nginx -d ui.headscale.example.com --non-interactive --agree-tos --email [email protected] sudo systemctl reload nginx
Browse to https://ui.headscale.example.com. In the Settings panel, set the API URL to https://headscale-grpc.example.com and paste the API key you generated in Step 9. You now have a browser-based view of users, nodes, pre-auth keys, and (read-only) the current ACL policy.
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
Client reports control server error: self signed certificate | server_url doesn't match the TLS cert, or Let's Encrypt didn't issue | Check openssl s_client -connect headscale.example.com:443 and curl -I https://headscale.example.com/health. Re-run Certbot. |
tailscale up hangs on "Waiting for login" | Nginx is blocking WebSocket upgrade or buffering | Ensure the Upgrade/Connection headers and proxy_buffering off are set in the vhost. |
Nodes show as offline but can ping each other | Normal — "offline" reflects whether the node has an active connection to Headscale, not peer reachability | Run tailscale status on the node to see real peer state. |
headscale nodes list from laptop returns rpc error: code = Unauthenticated | Expired or missing API key | Regenerate: sudo headscale apikeys create --expiration 90d and update local config. |
DERP fallback not working (tailscale netcheck shows "no working DERP") | UDP 3478 blocked or embedded DERP disabled | Check ufw status for 3478/udp allow. Confirm derp.server.enabled: true in config.yaml. |
| High CPU on server after ACL change | Headscale recomputes the full network map for every node on policy reload | Expected on large tailnets — typically settles within 30 seconds for <500 nodes. |
tailscale up error: invalid key: no matching preauth key | Key expired or not reusable and already consumed | Create a fresh key: sudo headscale preauthkeys create --user ops --reusable --expiration 24h. |
MagicDNS names not resolving | base_domain set to the same as server_url | They must differ. server_url: headscale.example.com + base_domain: tailnet.example.com is correct. |
Viewing Logs
Stream the Headscale log in real time:
sudo journalctl -u headscale -fBump the verbosity temporarily:
log:
level: debugThen:
sudo systemctl restart headscaleNginx access and error logs are the other place to look when clients cannot connect:
sudo tail -f /var/log/nginx/access.log /var/log/nginx/error.logFAQ
Is Headscale compatible with the official Tailscale clients?
Yes. Headscale speaks the same control protocol that the Tailscale-hosted service does, so the unmodified Tailscale clients on Linux, macOS, Windows, iOS, and Android connect to it by pointing at --login-server=https://your-headscale.example.com. You do not need a custom client, and you do not lose MagicDNS, exit nodes, or subnet routes.
How many nodes can a single Headscale VPS handle?
The Headscale daemon is remarkably efficient because it holds almost no state at runtime — node registrations live in the database and the process itself is mostly a pub/sub map distributor. On a CloudCore Starter (2 vCPU, 4 GB RAM) we comfortably run up to ~500 nodes with SQLite. At 1,000-5,000 nodes we recommend moving to Postgres and stepping up to CloudCore Professional (EUR 19.99/month) to absorb ACL recomputation spikes. Beyond 10,000 nodes, look at running multiple Headscale instances behind a load balancer with a shared Postgres — though the maintainers recommend considering Tailscale SaaS at that scale.
Do I need to run my own DERP relay?
Not strictly. By default (and with our config.yaml from Step 3) Headscale pulls Tailscale's public DERP map every 24 hours, so your clients automatically use Tailscale's globally distributed relay network as fallback. Running your own embedded DERP on the same VPS is useful for three reasons: (1) you keep fallback traffic inside your known infrastructure, (2) you often get lower latency for clients physically near the server, and (3) you remove the dependency on Tailscale's network entirely, enabling truly air-gapped deployments. The tradeoff is bandwidth: if many of your nodes are behind strict NAT and fail back to DERP, your VPS carries that traffic.
Can I migrate from Tailscale SaaS to Headscale without downtime?
Partially. The node identities (device keys) are not transferable — each device has to re-authenticate against the new coordination server. The practical migration is: stand up Headscale in parallel, create pre-auth keys, roll through your nodes calling tailscale logout && tailscale up --login-server=..., and finally turn off the Tailscale SaaS account. Users will see a brief per-node disconnect during the re-auth. For most small-to-medium networks this is a 30-60 minute evening job.
Is Headscale production-ready?
For most use cases, yes. Headscale is used in production by thousands of organisations and the project has a regular release cadence with an active maintainer community. The caveats: the gRPC API surface sometimes changes between minor versions (read the release notes before upgrading), and certain Tailscale features — Tailscale SSH enforcement, Funnel, Taildrop — are not implemented. If you rely on those, you'll want Tailscale SaaS. For pure WireGuard-mesh-with-ACLs-and-DNS, Headscale is solid.
How do I back up Headscale?
Three files are sufficient for full disaster recovery: /etc/headscale/config.yaml, /etc/headscale/acl.hujson, and /var/lib/headscale/db.sqlite (plus the private key files in /var/lib/headscale/). Snapshot them nightly to off-VPS storage. If you are using Postgres, add pg_dump -U headscale headscale > headscale.sql to the backup script. Restore is a file copy and systemctl restart headscale. We recommend automating with a simple cron job and shipping to S3-compatible object storage.
Next Steps
With Headscale running, here are recommended follow-ups:
- Wire in OIDC SSO -- Replace local users with your existing identity provider. Headscale supports Keycloak, Authentik, Zitadel, Google Workspace, and any generic OIDC server. Add the
oidcblock toconfig.yamland users log in with their corporate account instead of pre-auth keys.
- Advertise subnet routes -- Turn one node into a gateway for an on-prem LAN by running
sudo tailscale up --login-server=... --advertise-routes=192.168.1.0/24. Approve from the server withsudo headscale routes enable -r <route-id>. Instant site-to-site VPN.
- Set up an exit node -- Mark one well-connected VPS as an exit node. Clients with
tailscale up --exit-node=<ip>route all internet traffic through it. Useful for geo-fencing or consolidating egress IPs.
- Monitor with Prometheus and Grafana -- Headscale exposes Prometheus metrics on
127.0.0.1:9090/metrics. Scrape them with a Prometheus instance and build a dashboard tracking connected nodes, DERP fallback rate, and ACL recomputation time.
- Automate backups -- Script a nightly
sqlite3 /var/lib/headscale/db.sqlite .dump | gzip > /backup/headscale-$(date +%F).sql.gzfollowed by an upload to S3-compatible storage. Two-minute job, saves a day of debugging during DR.
- Integrate with your CI -- Use ephemeral pre-auth keys in GitHub Actions or GitLab CI so that build runners join the tailnet at job start, reach internal services through the mesh, and are automatically removed when the job exits.
Deploy Headscale on a CloudCore Starter VPS>
Running your own Tailscale coordination server takes less than an hour on the right infrastructure. Our CloudCore Starter plan gives you exactly what Headscale needs: 2 vCPU, 4 GB RAM, 50 GB NVMe, unmetered bandwidth — all on EU-based infrastructure for EUR 7.99/month.>
- Full root SSH access from minute one
- Free snapshot backups for the Headscale database
- IPv4 + IPv6 included
- Scale up to CloudCore Professional (EUR 19.99/month) or CloudCore Business (EUR 29.99+/month) as your tailnet grows>
Launch Your VPS Now and start mesh-networking in under 10 minutes.