Why self-host the control server
Tailscale is exceptional software — WireGuard performance, cross-platform clients, magic DNS that just works. Yet it does not carry your traffic: WireGuard packets travel directly from node to node, encrypted end to end. What runs in Tailscale's cloud is the control plane — the coordination server that exchanges public keys, lets peers discover each other, assigns 100.x.y.z addresses, publishes MagicDNS names and distributes ACLs and the DERP relay map. Without it, nodes cannot find each other. Headscale reimplements that coordination server in open-source Go, speaking the same protocol, so you can run it on your own infrastructure: your Tailscale clients only change their login URL.
Beyond the 6-user cap of the free tier, the main reason to self-host is data residency: the control plane knows which machines are on your network, their IPs, their names and your ACL policies. Running Headscale means that information stays on your VPS — not in someone else's cloud — and your network no longer depends on a third party's pricing decision or outage.
What self-hosted Headscale gives you
- Users and devices without a cap: no limit imposed by the software, no per-seat pricing — your mesh grows with your team, bounded only by the VPS resources.
- Official Tailscale clients everywhere: Linux, macOS, Windows, iOS, Android — no custom fork or alternative client required.
- WireGuard peer-to-peer tunnels: Headscale only handles key exchange and ACL distribution; all actual traffic flows directly over WireGuard between peers.
- Magic DNS: resolve every node in your mesh by hostname (
laptop.mesh.yourdomain.com) without touching your public DNS. - HuJSON policies: the same policy file format as Tailscale, with ACLs and Grants — define which users and devices can reach which services.
- DERP relay support: use Tailscale's public DERP map for NAT traversal, or enable Headscale's embedded DERP server to keep the relays in-house too.
- headscale API and CLI: create users, issue pre-auth keys and manage nodes programmatically.
- Registration without a tailscale.com account: your colleagues join the network with a key you generate, or through the OpenID Connect provider you already use (Keycloak, Authelia, Google Workspace…).
- Subnet routers, exit nodes, Taildrop and Tailscale SSH: Tailscale's base features are supported by the server.
- Under 50 MB of RAM at idle — runs comfortably on a 1 GB VPS alongside other workloads.
Requirements
A VPS with at least 1 vCPU and 512 MB of RAM (1 GB leaves headroom for the system) running Ubuntu 22.04 or newer, or Debian 12 or newer — the distributions targeted by the official packages. For the Docker route, install Docker and Docker Compose v2. A public IP is required; a dual-stack IPv4 + IPv6 setup is recommended.
A domain name pointing to the VPS is required: Tailscale clients connect to the control server by URL, and the project strongly recommends serving Headscale over HTTPS on port 443, since the Tailscale client assumes that port in some situations. Plan one subdomain for the server (vpn.yourdomain.com) and a separate one for MagicDNS names (mesh.yourdomain.com): Headscale refuses a MagicDNS domain equal to the server_url domain.
Ports to open on the VPS
- TCP 443: mandatory. Clients register and receive the network map over HTTPS; it is the only port the coordination server cannot do without.
- TCP 80: only for Let's Encrypt HTTP-01 validation and the redirect to HTTPS.
- UDP 3478: only if you enable the embedded DERP server, which needs it for STUN. With Tailscale's public DERP map there is no need to open it.
- TCP 8080 (Headscale), 9090 (metrics) and 50443 (gRPC): keep them on
127.0.0.1. The reverse proxy talks to 8080 locally; never expose the gRPC admin interface unless you need it.
Deploy Headscale with Docker Compose
Write the Compose file
The stack is a single service: the official
docker.io/headscale/headscale:0.29.7image, the latest stable release as of 7 November 2026, started with theservecommand. Headscale keeps everything — keys, database, node records — in two host directories: the configuration, mounted read-only at/etc/headscale, and the data, at/var/lib/headscale. No external database or cache service is needed; SQLite is built in.mkdir -p /opt/headscale/config /opt/headscale/lib cat > /opt/headscale/compose.yaml <<'EOF' services: headscale: image: docker.io/headscale/headscale:0.29.7 container_name: headscale restart: unless-stopped read_only: true tmpfs: - /var/run/headscale ports: - "127.0.0.1:8080:8080" volumes: - /opt/headscale/config:/etc/headscale:ro - /opt/headscale/lib:/var/lib/headscale command: serve healthcheck: test: ["CMD", "headscale", "health"] EOFThe port is only published on
127.0.0.1: the reverse proxy is what faces the internet.Generate the configuration
Start from the example file of the exact version you deploy, then change only the keys below.
server_urlis the HTTPS URL your Tailscale clients will connect to;listen_addrmust listen on0.0.0.0inside the container. The database is declared underdatabase(type: sqlite). Only the noise key path remains to set: Headscale generates it on first start if the file does not exist.curl -Lo /opt/headscale/config/config.yaml \ https://raw.githubusercontent.com/juanfont/headscale/v0.29.7/config-example.yamlserver_url: https://vpn.yourdomain.com listen_addr: 0.0.0.0:8080 noise: private_key_path: /var/lib/headscale/noise_private.key database: type: sqlite sqlite: path: /var/lib/headscale/db.sqlite dns: magic_dns: true base_domain: mesh.yourdomain.comOn ServOrbit, the provisioning job writes
config.yamlfrom your domain automatically.Start the container and verify
Run
docker compose up -dfrom/opt/headscale. Headscale generates its noise key, then starts listening on port 8080. Check the health endpoint:curl -s http://127.0.0.1:8080/healthshould return{"status":"pass"}. The image is built on a distroless base with no shell, but the headscale CLI can be called directly:docker exec headscale headscale versionconfirms the running version, anddocker logs --follow headscaleshows the logs.Create a user and generate an auth key
Headscale organizes devices into 'users' (formerly called namespaces). Create one, then note its numeric identifier: the
--useroption ofpreauthkeys createexpects that ID, not the name.docker exec headscale headscale users create myteam docker exec headscale headscale users list docker exec headscale headscale preauthkeys create --user 1 --reusable --expiration 24hWithout
--reusablethe key works only once, and without--expirationit expires after one hour. Copy the printed key — you will use it when registering each device.Register a device
On any device with the official Tailscale client, point it at your Headscale instance:
tailscale up --login-server https://vpn.yourdomain.com --authkey <your-preauth-key>. The device appears in Headscale's node list within seconds:docker exec headscale headscale nodes list. Without a key, the client opens a page showing an authentication ID; approve it on the server withdocker exec headscale headscale auth register --user myteam --auth-id <AUTH_ID>. Once registered, devices reach each other by their mesh IP (100.64.x.y) or by magic DNS hostname.Define access control policies
As long as no policy is loaded, Headscale lets all traffic flow between nodes — fine for a small trusted team, not once developer laptops, contractors and production servers share the network. Create
/opt/headscale/config/policy.hujsonand declare it inconfig.yamlwithpolicy.path: /etc/headscale/policy.hujson. Headscale uses the same HuJSON format as Tailscale: define user groups, tag devices as servers or workstations, and write rules stating which groups can reach which ports. The project recommends Grants over ACLs, which Tailscale considers legacy. Validate the file withheadscale policy check --file <file>, then reload: the policy is only re-read on reload (docker kill --signal=HUP headscale, orsystemctl reload headscaleon a native install). Keep the file in a private Git repository to retain a history of access changes.Logging in for the first time
Headscale has no web interface and no account: it is the control server of your private network, driven from the command line. Over SSH:
docker exec -it headscale headscale users create monuser, then the key withpreauthkeys create --user <ID> --reusable --expiration 24h, and connect your machines withtailscale up --login-server https://<your-domain> --authkey <key>. The graphical apps can target a custom server too: on macOS, Option + click the Tailscale icon, open the Debug menu, thenCustom Login Server; on iOS,Use custom coordination serverin the login menu; on Android,Use an alternate server; on Windows,tailscale login --login-serverin PowerShell.
Without Docker: the official .deb package
The project publishes .deb packages for Debian and Ubuntu and recommends them on those distributions: they create the headscale system user, ship a default configuration and install the systemd unit. It is the simplest route if Headscale runs alone on its VPS or if you prefer a native service to a container. The management commands are the same as above, without the docker exec headscale prefix.
Install Headscale from the .deb package
Download and install the package
Fetch the package for the version you want from the GitHub releases page, for your architecture (
amd64orarm64), and install it withapt, which resolves dependencies:HEADSCALE_VERSION="0.29.7" HEADSCALE_ARCH="amd64" wget --output-document=headscale.deb \ "https://github.com/juanfont/headscale/releases/download/v${HEADSCALE_VERSION}/headscale_${HEADSCALE_VERSION}_linux_${HEADSCALE_ARCH}.deb" sudo apt install ./headscale.debThen check:
headscale versionshould print0.29.7.Adapt the configuration
The package puts the configuration in
/etc/headscale/config.yamland an up-to-date example in/usr/share/doc/headscale/examples/config-example.yaml. Change the same keys as for Docker (server_url,database,dns.base_domain), but keeplisten_addr: 127.0.0.1:8080: the service runs on the host, and only the reverse proxy should reach it. Data lives in/var/lib/headscale.Restart and check the service
Restart the service so it re-reads the configuration, then check its state and logs:
sudo systemctl restart headscale sudo systemctl status headscale sudo journalctl -u headscale -fThe output should show
Active: active (running). On first start, the most common errors are aserver_urlwithout thehttps://scheme and a MagicDNS domain identical to the server's.Use the CLI without being root
The CLI talks to the service through the Unix socket
/var/run/headscale/headscale.sock, which by default only theheadscaleuser and root can access. Usesudo, run the commands as theheadscaleuser, or add your account to theheadscalegroup. Check access withheadscale users list.
Serve Headscale over HTTPS behind Nginx
Tailscale clients talk to Headscale with their own protocol, carried over a WebSocket connection opened with POST, whose Upgrade header is tailscale-control-protocol. The reverse proxy must therefore pass the Upgrade and Connection headers through unchanged and must not buffer responses. The configuration below follows the example in the project documentation; get the certificate first with certbot (certbot certonly --nginx -d vpn.yourdomain.com).
map $http_upgrade $connection_upgrade {
default keep-alive;
'' close;
}
server {
listen 80;
server_name vpn.yourdomain.com;
location = /generate_204 { return 204; }
location / { return 301 https://$server_name$request_uri; }
}
server {
listen 443 ssl;
http2 on;
server_name vpn.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/vpn.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/vpn.yourdomain.com/privkey.pem;
location / {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
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;
proxy_buffering off;
proxy_pass http://127.0.0.1:8080;
}
}Leave tls_cert_path and tls_key_path empty in config.yaml: Nginx terminates TLS, so the listening without TLS warning at startup is expected. For Headscale's logs to show clients' real IPs, declare the proxy as a trusted source. On a native install the loopback is enough; with Docker, also add the Docker bridge network (docker network inspect), because the container sees the proxy arriving from its gateway:
trusted_proxies:
- 127.0.0.1/32
- ::1/128Caddy works as well, with a plain reverse_proxy 127.0.0.1:8080. The Cloudflare proxy and Cloudflare Tunnel, however, are not supported, since they do not handle WebSocket POSTs: the server's DNS record must stay DNS-only.
Pair Headscale with Authelia for MFA-protected network access
Headscale handles network-layer access control (which IPs can talk to which peers), but it does not enforce application-layer authentication. Pair it with Authelia on the same VPS: route your internal services (reachable only through the mesh) through Authelia's forward-auth layer. Users must complete MFA to access services even once they are on the mesh — defense in depth for sensitive workloads.
Check that nodes can see each other
A node listed by headscale nodes list only proves it reached the coordination server. Before routing real traffic through the mesh, check the end-to-end path from the clients themselves.
Check the mesh from a client
Read the network state
On a registered node, run
tailscale status. It lists peers with their mesh IP, name and link type:directmeans the WireGuard tunnel is established with no intermediary,relaymeans it goes through a DERP relay — working, but with higher latency.Test connectivity
Take a peer's IP from
tailscale status, thenping 100.64.0.2, or bettertailscale ping <node-name>, which tells you whether the reply arrives directly or through a relay. If the peer is listed but does not answer,tailscale netcheckmeasures latency to each DERP region and reports filtered outbound UDP.Test MagicDNS resolution
With
dns.magic_dns: true, every node answers to its short and full names:ping node-nameorping node-name.mesh.yourdomain.com. No public DNS record is needed for these internal names. If resolution fails,tailscale dns statusshows the DNS configuration the client received from the server.
Use case: SSH between VPSs without exposing port 22
Once your development and production VPSs are registered on the same mesh, SSH no longer needs to listen on the public interface: the connection uses the mesh address, inside the WireGuard tunnel. Keep console access open at your provider before touching the firewall, in case a rule locks you out.
Restrict SSH to the mesh network
Find the mesh interface and address
On each VPS, the Tailscale client creates the
tailscale0interface.tailscale ip -4prints the machine's mesh address; note the production server's, for example100.64.0.3.Allow SSH only through tailscale0
On the production VPS, allow SSH on the mesh interface before denying it elsewhere — UFW applies the first matching rule:
ufw allow in on tailscale0 to any port 22 proto tcp ufw deny 22/tcp ufw enable ufw status verbosePort 22 is no longer reachable from the internet, but stays open to every mesh node your policy allows.
Connect through the mesh
From the development VPS or your registered workstation, connect by IP or MagicDNS name:
ssh [email protected]orssh [email protected]. Everything travels inside the encrypted tunnel; then restrict in the Headscale policy which groups can reach this server's port 22.
Migrate a team already on Tailscale
The switch requires no reinstallation: the official client accepts the --login-server option, so you only log it out of the old network and back into your server. Each machine drops for a few seconds during the operation. Because your server assigns 100.x addresses and MagicDNS names itself, plan to update the scripts, firewall rules and configuration files that reference the old ones.
Move clients over to Headscale
Carry over the access policy
Copy your tailnet's policy file from the Access controls editor in the Tailscale console and start from it: the HuJSON format is the same. Check it with
headscale policy checkbefore the switch — some features, such as IP sets, are not supported, and only a subset of autogroups exists.Log each client out, then back in
On Linux or from the command line, chain the logout and the reconnection with a pre-auth key:
tailscale logout tailscale up --login-server https://vpn.yourdomain.com --authkey <PREAUTHKEY>On macOS, iOS, Android and Windows, log the account out in the app, then add the custom server as described above.
Start with test machines
Switch a test workstation and a test server first, check
tailscale statusand name resolution, then the development machines and, last, production. While some machines remain on the other side, the two networks cannot see each other: plan to move a service and its clients in the same window.Close or downgrade the Tailscale account
Once all nodes are migrated and verified, close the tailnet or move back to the Personal plan if you keep a personal use. Generate a fresh pre-auth key for future registrations rather than keeping the migration key forever.
Headscale vs Tailscale Cloud
Tailscale Cloud remains the easier path for small teams: no server to maintain, automatic upgrades, integrated billing — and the Personal plan stays free up to 6 users. Beyond that, the Standard plan is listed at $8 per user per month on the pricing grid in force since 8 April 2026: $40 a month for five people, $80 for ten. Headscale costs the price of the VPS whatever the headcount — on ServOrbit, the VPS Solo (2 vCPU / 4 GB) at 99 DH/month is enough for dozens of nodes — plus operating time: upgrades, backups, monitoring.
Headscale therefore makes sense beyond 6 users, when data residency requires keeping your network topology off third-party servers, or when you want full auditability of your coordination logs. The clients are identical — moving from Tailscale Cloud to Headscale means re-running tailscale up --login-server on each device.
Tailscale Personal, Tailscale Standard and Headscale
Scroll the table
| Criterion | Tailscale Personal | Tailscale Standard | Headscale on a VPS |
|---|---|---|---|
| Price | Free | $8 per user per month | The cost of the VPS |
| Users | Up to 6 | No cap | No software limit |
| User devices | No cap | No cap | No software limit |
| Coordination server | Tailscale cloud | Tailscale cloud | Your VPS, under your control |
| Client | Official Tailscale client | Official Tailscale client | Official Tailscale client (`--login-server`) |
| MagicDNS, ACLs, Grants | Yes | Yes | Yes, on your own domain |
| Funnel and Serve | Offered by Tailscale | Offered by Tailscale | Not supported |
| Maintenance | None (managed service) | None (managed service) | Upgrades, backups, monitoring |
What Headscale does not replace
Headscale implements the coordination server, not the whole commercial platform. Its own documentation lists what is missing: Tailscale Funnel (publishing a local service to the internet), Tailscale Serve (sharing it in the tailnet through a local proxy) and network flow logs are not supported; OIDC groups cannot be used in ACLs. There is no official web console either: everything goes through the CLI or the API. If your use is limited to mesh connectivity, MagicDNS, access policies, subnet routers and exit nodes, Headscale covers the need; if you actively use Funnel or Serve, weigh those features against the savings before migrating — or look at NetBird, which self-hosts its admin interface too.
Backups and upgrades
- Back up before every upgrade: Headscale applies its database migrations when the new version starts. Stop the service, copy the configuration and data, then start it again:
cd /opt/headscale && docker compose stop,cp -aR /opt/headscale /opt/headscale.backup-$(date +%Y%m%d%H%M%S),docker compose up -d. - The data directory holds
db.sqliteandnoise_private.key: losing that key changes the server's identity and forces every node to re-register. Copy it off the VPS as well, for example to object storage. - Upgrade one version at a time: the project requires going through each stable minor version (for example
0.27, then0.28, then0.29), always at its latest patch, without skipping any. - Read the release notes on GitHub before each upgrade: they flag breaking configuration changes. Then compare your
config.yamlwith the new version's example file.
Troubleshooting: the most common errors
- The client cannot register: check that
https://vpn.yourdomain.com/healthanswers from outside, thatserver_urlexactly matches the certificate's domain, and that the proxy passes theUpgradeandConnectionheaders. - Connections in
relayinstead ofdirect: a strict NAT or a firewall blocks UDP between the two nodes. Runtailscale netcheckon both sides to see what is filtered; traffic still flows, through the DERP relays. - Node listed as
offline: the client reached the server but does not keep the session. Checksystemctl status tailscaledandjournalctl -u tailscaledon the node; a local firewall cutting outbound connections is the most common cause. - Headscale refuses to start after an upgrade: a configuration key was renamed or removed. The log names it; refer to the example file of the installed version.
- MagicDNS does not resolve: check that
dns.magic_dnsistrueand thatdns.base_domaindiffers from theserver_urldomain, then reconnect the client so it fetches the new DNS configuration.