All articles
TutorialsMay 04, 2026 · 18 min read

Self-Host Headscale on a VPS - Run Your Own Tailnet

Self-Host Headscale on a VPS - Run Your Own Tailnet

Tailscale is wonderful, but the free tier caps users and the control plane is a third party you have to trust with your network metadata. If that bothers you, or you just want to know exactly where your coordination server lives, Headscale is the answer.

Headscale is an open-source, self-hosted implementation of the Tailscale control server. The official Tailscale clients (macOS, iOS, Android, Windows, Linux) connect to it without modification, you keep all the magic - magic DNS, NAT traversal, subnet routing, exit nodes - and you stop sending node metadata to a SaaS.

This guide walks through a clean Headscale deployment on a VPS: Docker, Caddy for automatic HTTPS, a real user, a pre-auth key, a Linux node joining the tailnet, and an optional web UI for managing it all.

Headscale is not affiliated with Tailscale Inc. Treat it as an independent project that tracks the Tailscale wire protocol. Pin your image tag in production so a new release does not surprise you.

TL;DR

  • Point a subdomain like headscale.example.com at your VPS
  • Install Docker and open ports 80 and 443
  • Run Headscale and Caddy from a single docker-compose.yml
  • Create a user, generate a pre-auth key, and connect a node with tailscale up --login-server
  • Add the optional headscale-admin web UI to manage users from a browser
  • Schedule a nightly backup of the SQLite database and config

Total time: around 20 minutes.

What You Need

  • A VPS with at least 512 MB RAM running Ubuntu 22.04 or 24.04
  • A domain you can add DNS records to
  • Ports 80 and 443 open to the internet (Let's Encrypt)
  • Root or sudo access
  • The official Tailscale client on the device you want to join first

If you have not picked a VPS yet, anything in the entry tier is enough. Headscale itself idles around 30 MB of RAM.

When Headscale, When Tailscale

Both are great, and they share clients. Pick based on operational appetite.

  • Tailscale (the SaaS) is what you want when you do not want to run anything. The free tier covers most personal use, and SSO, ACL editor, and admin UI are mature.
  • Headscale is what you want when you need full control of the coordinator, no per-user limits, an air-gapped or sovereign deployment, or you simply prefer self-hosted by default.

If you are coming from plain WireGuard, see our WireGuard guide for a comparison of how the two protocols differ in shape.

Step 1: Point a Subdomain at Your VPS

In your DNS provider, add an A record:

headscale.example.com → YOUR_VPS_IPV4

Add an AAAA record too if you have IPv6. Verify before continuing:

dig +short headscale.example.com

The output must match the VPS IP. Caddy needs a working DNS record before it can request a certificate.

Step 2: Install Docker and Docker Compose

On a fresh Ubuntu box:

sudo apt update sudo apt install -y ca-certificates curl sudo install -m 0755 -d /etc/apt/keyrings sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ -o /etc/apt/keyrings/docker.asc sudo chmod a+r /etc/apt/keyrings/docker.asc echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \ https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Sanity-check:

docker --version docker compose version

Step 3: Open the Firewall

If you use UFW:

sudo ufw allow 22/tcp sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw enable

Headscale itself does not need any extra UDP ports for the basics. Clients use the public DERP relays Tailscale operates by default. We will switch to a private DERP later if you want.

Step 4: Create the Project Layout

sudo mkdir -p /opt/headscale/{config,data,caddy-data,caddy-config} cd /opt/headscale

Everything Headscale persists lives under /opt/headscale. That is the one path you need to back up.

Step 5: Write the Headscale Config

Create /opt/headscale/config/config.yaml:

server_url: https://headscale.example.com listen_addr: 0.0.0.0:8080 metrics_listen_addr: 127.0.0.1:9090 grpc_listen_addr: 127.0.0.1:50443 grpc_allow_insecure: false noise: private_key_path: /var/lib/headscale/noise_private.key prefixes: v4: 100.64.0.0/10 v6: fd7a:115c:a1e0::/48 allocation: sequential derp: server: enabled: false urls: - https://controlplane.tailscale.com/derpmap/default auto_update_enabled: true update_frequency: 24h disable_check_updates: false ephemeral_node_inactivity_timeout: 30m database: type: sqlite sqlite: path: /var/lib/headscale/db.sqlite log: level: info format: text dns: magic_dns: true base_domain: tailnet.example.com nameservers: global: - 1.1.1.1 - 9.9.9.9 search_domains: [] unix_socket: /var/run/headscale/headscale.sock unix_socket_permission: "0770" policy: mode: file path: ""

Two things that bite people:

  • base_domain must be a domain you control, and it must not be the same as server_url's host. Pick something like tailnet.example.com or ts.example.com. You do not need to publish it on real DNS - magic DNS handles resolution inside the tailnet.
  • prefixes.v4 is the carrier-grade NAT range Tailscale uses (100.64.0.0/10). Leave it unless you have a strong reason.

Set ownership so the container can read it:

sudo chown -R 1000:1000 /opt/headscale

Step 6: Write the Compose File

Create /opt/headscale/docker-compose.yml:

services: headscale: image: headscale/headscale:0.26.1 container_name: headscale restart: unless-stopped command: serve volumes: - ./config:/etc/headscale - ./data:/var/lib/headscale networks: - hsnet caddy: image: caddy:2 container_name: caddy restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - ./caddy-data:/data - ./caddy-config:/config networks: - hsnet networks: hsnet:

Pin headscale/headscale to a specific tag - Headscale moves quickly and a latest tag has bitten people during minor version bumps. Check the project's release page for the current stable tag.

Step 7: Write the Caddyfile

Create /opt/headscale/Caddyfile:

headscale.example.com { encode zstd gzip reverse_proxy headscale:8080 }

Caddy will request a Let's Encrypt cert on first boot and renew it on its own. Tailscale's noise protocol runs over plain HTTPS, so a single reverse-proxy block covers everything the clients need.

Step 8: Start the Stack

cd /opt/headscale sudo docker compose up -d sudo docker compose logs -f headscale

Once you see listening and serving HTTP on 0.0.0.0:8080 and Caddy reports a successful cert, hit the URL from your laptop:

curl -sSI https://headscale.example.com/health

You should get HTTP/2 200. Anything else, jump to the troubleshooting section.

Step 9: Create a User and a Pre-Auth Key

Headscale ships its admin CLI inside the image. Run it through docker exec:

sudo docker exec headscale \ headscale users create alice

Take note of the user ID printed in the output (usually 1 for the first user).

Now mint a pre-auth key. Pre-auth keys let nodes join without a browser flow, which is what you want on headless servers and CI runners.

sudo docker exec headscale \ headscale preauthkeys create --user 1 --reusable --expiration 24h

Copy the key (a long hex string) somewhere you trust. It is only printed once.

A reusable pre-auth key with a long expiry is effectively a credential that lets anyone enroll a node into your tailnet. Use a short expiry, mark it ephemeral when possible, and revoke it as soon as you are done.

Step 10: Connect Your First Node

On the Linux box you want to join, install the official Tailscale client (yes, official - Headscale only replaces the control plane):

curl -fsSL https://tailscale.com/install.sh | sh

Then bring it up against your Headscale instance:

sudo tailscale up \ --login-server https://headscale.example.com \ --authkey YOUR_PREAUTH_KEY_HERE \ --accept-dns=true

Verify on the server:

sudo docker exec headscale headscale nodes list

You should see your hostname, the user alice, and an IP from 100.64.0.0/10. From any other node in the tailnet, that IP should now ping.

Step 11: Connect a macOS or iOS Client

The App Store builds talk only to Tailscale's coordinator by default. For Headscale you want the alternate builds:

  • macOS standalone app: the Tailscale-x.y.z-headscale.pkg bundle from the Headscale releases wiki.
  • iOS: open https://headscale.example.com/apple in Safari and follow the profile-install flow it generates.

Once installed, use the gear icon to override the coordination URL, then sign in with a fresh pre-auth key. Same drill as Linux.

Step 12: Optional - Add a Web UI

Headscale's CLI is fine, but a UI makes daily ops easier. headscale-admin is a single-page app that talks to Headscale's API.

First, mint an API key:

sudo docker exec headscale \ headscale apikeys create --expiration 90d

Add a service to /opt/headscale/docker-compose.yml:

admin: image: ghcr.io/goodieshq/headscale-admin:latest container_name: headscale-admin restart: unless-stopped networks: - hsnet

Append a route to the Caddyfile so it lives at /admin:

headscale.example.com { encode zstd gzip handle_path /admin* { reverse_proxy admin:80 } reverse_proxy headscale:8080 }

Reload:

sudo docker compose up -d sudo docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile

Visit https://headscale.example.com/admin, paste your API key and the base URL, and you have a graphical view of users, nodes, pre-auth keys, and routes.

Step 13: Backups

Everything Headscale persists lives in /opt/headscale. The SQLite database is small (a few MB even with hundreds of nodes) and safe to copy with the .backup SQLite command.

Create /usr/local/bin/headscale-backup.sh:

#!/usr/bin/env bash set -euo pipefail BACKUP_DIR="/var/backups/headscale" DATE="$(date +%F)" mkdir -p "$BACKUP_DIR" docker exec headscale \ sqlite3 /var/lib/headscale/db.sqlite \ ".backup '/var/lib/headscale/db.backup.sqlite'" tar -czf "$BACKUP_DIR/headscale-$DATE.tar.gz" \ -C /opt/headscale config data find "$BACKUP_DIR" -name "headscale-*.tar.gz" -mtime +14 -delete

Make it executable and schedule it:

sudo chmod +x /usr/local/bin/headscale-backup.sh echo "20 3 * * * root /usr/local/bin/headscale-backup.sh" | \ sudo tee /etc/cron.d/headscale-backup

For off-site safety, push the resulting tarball to object storage with restic on the same schedule.

Troubleshooting

Caddy cannot issue a certificate. DNS has not propagated, or ports 80/443 are blocked at the provider firewall. Check with dig +short and your provider's network panel.

Clients connect but cannot reach each other. Magic DNS is off or base_domain is wrong. Confirm headscale nodes list shows IPs and that each node has --accept-dns=true. From a node, tailscale status should list peers.

headscale serve fails with "noise private key" errors. Ownership inside the container is wrong. Re-run sudo chown -R 1000:1000 /opt/headscale and restart.

iOS profile install loops. You set server_url to a host that does not match the cert Caddy issued. The server_url in config.yaml and the certificate hostname have to be byte-for-byte the same.

Pre-auth keys reject with "expired". Headscale uses UTC. If your VPS clock has drifted, NTP is broken. Run timedatectl status and re-enable systemd-timesyncd.

Going Further

  • Subnet routes and exit nodes. tailscale up --advertise-routes=10.0.0.0/24 plus headscale routes enable lets a tailnet member act as a gateway into a private LAN. Same pattern works for exit nodes.
  • ACLs. Headscale parses Tailscale's HuJSON ACL format. Drop a policy file at /opt/headscale/config/acl.hujson and point policy.path at it to lock down which users can reach which nodes.
  • Private DERP. If you have a second VPS in a different region, you can run Headscale's built-in DERP server there to avoid relying on Tailscale's public relays for fallback. Toggle derp.server.enabled and open UDP 3478.
  • OIDC login. Drop in Authentik, Keycloak, or Google as an OIDC provider and let users authenticate through SSO instead of pre-auth keys.

That's it. You now have your own coordination server on a VPS you control, talking the same protocol the official clients already speak.


Need a VPS that's a good fit for a coordination server like this? Our Linux plans include fast NVMe storage, IPv6, and a generous bandwidth allowance. See the options.