Self-host Tailscale with Headscale on a VPS
Run your own Tailscale coordination server with Headscale. Same client, same UX, no third-party control plane, no per-seat pricing.

Tailscale is brilliant. The catch is the control plane is theirs, the per-seat pricing escalates, and you get to wonder how comfortable you are with a third party brokering your private network. Headscale is the open-source coordination server that speaks the same protocol as the Tailscale client. Same UX, no SaaS dependency.
What you need
- A small VPS for Headscale itself (our 1 vCore, 2 GB RAM smallest plan is plenty).
- A domain name pointed at it.
- Caddy as a TLS-terminating reverse proxy in front of Headscale (we install it below).
- Ports 80 and 443 open to the internet, so Caddy can obtain a Let's Encrypt certificate.
- Tailscale clients installed on the devices you want to mesh together.
Install Headscale
HEADSCALE_VERSION=0.23.0
ARCH=$(dpkg --print-architecture)
curl -fL -o /tmp/headscale.deb \
"https://github.com/juanfont/headscale/releases/download/v${HEADSCALE_VERSION}/headscale_${HEADSCALE_VERSION}_linux_${ARCH}.deb"
sudo apt install -y /tmp/headscale.deb
This pins 0.23.0, which matches the config shown below. Check the GitHub releases page and bump HEADSCALE_VERSION if you want a newer release; the tag and the filename share the variable, so they can't drift apart.
Configure
Edit /etc/headscale/config.yaml:
server_url: https://headscale.example.com
listen_addr: 127.0.0.1:8080
metrics_listen_addr: 127.0.0.1:9090
grpc_listen_addr: 127.0.0.1:50443
grpc_allow_insecure: false
private_key_path: /var/lib/headscale/private.key
noise:
private_key_path: /var/lib/headscale/noise_private.key
ip_prefixes:
- fd7a:115c:a1e0::/48
- 100.64.0.0/10
derp:
server:
enabled: false
urls:
- https://controlplane.tailscale.com/derpmap/default
dns:
magic_dns: true
base_domain: tailnet.example.com
database:
type: sqlite3
sqlite:
path: /var/lib/headscale/db.sqlite
log:
level: info
For >50 nodes, swap SQLite for Postgres. Otherwise SQLite is fine.
The dns block is not optional. Headscale 0.23.0 enables MagicDNS by default, and if base_domain is empty it refuses to start with dns.base_domain must be set when using MagicDNS. Pick a domain that is not part of server_url (Headscale rejects that too); it becomes the DNS suffix your nodes get, like laptop.tailnet.example.com. If you don't want MagicDNS, set magic_dns: false instead and you can skip base_domain.
TLS via Caddy
Tailscale clients will only talk to the control plane over HTTPS, so the reverse proxy is not optional either. Install Caddy:
sudo apt install -y caddy
Add this block to /etc/caddy/Caddyfile:
headscale.example.com {
reverse_proxy localhost:8080
}
Caddy fetches the certificate automatically, which only works if ports 80 and 443 are reachable from the internet. If you run ufw, open them before reloading: sudo ufw allow 80/tcp and sudo ufw allow 443/tcp. Then:
sudo systemctl reload caddy
(See the Caddy guide for more on Caddy itself.)
Start
sudo systemctl enable --now headscale
sudo journalctl -u headscale -f
Once the log shows Headscale listening, confirm the full path through Caddy works before you touch any client:
curl -I https://headscale.example.com
Any HTTP response over a valid certificate is a pass (a 404 from Headscale's root path is normal). A TLS error or a timeout means DNS, the firewall, or the Caddyfile needs fixing first; no client will register until this works.
Create a user namespace
sudo headscale users create alice
Connect a client
One thing before you run this: pointing a device at your Headscale server switches its control plane and drops it off whatever tailnet it is currently on, immediately. If the device was previously joined to SaaS Tailscale (or another Headscale), run tailscale logout on it first. And never run it over a connection that itself rides on Tailscale, or you'll cut off your own session mid-command.
On the client device:
tailscale up --login-server=https://headscale.example.com
The CLI prints a URL like https://headscale.example.com/register/mkey:.... Open it in a browser on any device; the page shows the exact register command to run. Back on the Headscale server, run it with that mkey value:
sudo headscale nodes register --user alice --key mkey:...
Or, generate a pre-auth key and bake it into the client command. Treat the key like a password: it's a bearer credential, and anyone who gets the tskey-auth-... string can enroll their own devices into your tailnet until it expires. It also ends up in the client's shell history. Skip --reusable if you're only enrolling one device, and expire the key once you're done with it (headscale preauthkeys expire):
sudo headscale --user alice preauthkeys create --reusable --expiration 24h
# returns: tskey-auth-...
tailscale up --login-server=https://headscale.example.com --auth-key=tskey-auth-...
Subnet routing
If one of your devices should expose its LAN to the tailnet (a homelab, a colo box), that device has to forward packets between interfaces. Enable kernel IP forwarding on it first, or the routes will show as enabled later but carry no traffic:
echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-tailscale.conf
echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf
sudo sysctl -p /etc/sysctl.d/99-tailscale.conf
Then advertise the routes:
tailscale up --login-server=https://headscale.example.com \
--advertise-routes=192.168.1.0/24
Approve the routes from the Headscale server:
sudo headscale routes list
sudo headscale routes enable -r ROUTE_ID
One more catch: Linux clients ignore advertised routes unless they opt in. On each Linux peer that should reach 192.168.1.0/24, rerun tailscale up with --accept-routes added, repeating the flags it already uses:
tailscale up --login-server=https://headscale.example.com --accept-routes
ACLs
Headscale supports the same ACL syntax as Tailscale. Save this as /etc/headscale/acl.json:
{
"acls": [
{
"action": "accept",
"src": ["alice"],
"dst": ["*:*"]
}
]
}
Understand what happens before you load it: the moment a policy is active, the tailnet flips from implicit allow-all to default-deny. Anything not explicitly allowed by a rule is dropped instantly, subnet routes and live sessions included. The example above only covers alice; devices belonging to any other user lose all tailnet connectivity the moment it applies. Make sure every user you created is matched by a rule, and don't apply a policy over a connection that itself depends on the tailnet.
Point Headscale at the file in /etc/headscale/config.yaml:
policy:
mode: file
path: /etc/headscale/acl.json
Then restart to load it:
sudo systemctl restart headscale
You may see headscale policy set in other write-ups; on 0.23.0 that command only works when policy.mode is database, so with this config the file plus a restart is the whole procedure. For real deployments, write actual rules. The default-deny model is your friend.
Backups
Three files matter, all under /var/lib/headscale/: db.sqlite, private.key, noise_private.key. Be clear about what that backup is: it holds the control plane's private keys and the full node database, so anyone who obtains it can impersonate your coordination server. Keep the archive mode 0600 and encrypt it before it leaves the box (age or restic both do the job). One more trap: don't tar db.sqlite while Headscale is running. It uses SQLite in WAL mode, and a naive copy of a live database can be inconsistent. Take a proper snapshot with sqlite3 instead, or stop the service for the copy:
sudo apt install -y sqlite3
sudo sqlite3 /var/lib/headscale/db.sqlite ".backup '/var/lib/headscale/db-backup.sqlite'"
sudo tar czf /root/headscale-backup.tar.gz -C /var/lib/headscale \
db-backup.sqlite private.key noise_private.key
sudo chmod 600 /root/headscale-backup.tar.gz
sudo rm /var/lib/headscale/db-backup.sqlite
Run that nightly from cron and ship the encrypted result somewhere off the VPS.
Restoring is destructive: dropping the files in place overwrites the live database and keys irreversibly, and every node registered since the backup was taken is gone. Stop the service before you touch anything:
sudo systemctl stop headscale
sudo tar xzf /root/headscale-backup.tar.gz -C /var/lib/headscale
sudo mv /var/lib/headscale/db-backup.sqlite /var/lib/headscale/db.sqlite
sudo chown headscale:headscale /var/lib/headscale/db.sqlite
sudo systemctl start headscale
What you lose vs SaaS Tailscale
- DERP relays: by default Headscale uses Tailscale's public DERP servers for NAT traversal fallback. You can run your own (
derper) but most homes don't need to. - Magic DNS: works, but slightly more setup than the SaaS toggle.
- Tailscale Funnel and SSH integrations are partial; check the Headscale docs for the current support matrix.
For most homelab and small-team uses, Headscale is enough, free, and yours. We run our internal admin network on a single Headscale VPS that costs less than one seat of Tailscale Premium.
Continue reading