How to Deploy Headscale on a VPS
Updated Jul 2026
verified on Ubuntu 26.04 · Jul 2026Self-host Headscale on your own VPS — an open-source coordination server for the official Tailscale clients, so your mesh VPN's control plane runs on infrastructure you own.
- A VPS with 1 vCPU / 512 MB–1 GB RAM (Headscale itself runs comfortably in ~256 MB; the rest is headroom for the OS and reverse proxy)
- A fresh Ubuntu 26.04 server with root/sudo SSH access, Docker + Compose installed
- A domain or sslip.io hostname pointed at the server for TLS — Tailscale clients need a stable public HTTPS endpoint to connect to
What Headscale is
Headscale is an open-source, self-hosted implementation of the
Tailscale coordination server — the control-plane service that Tailscale clients talk
to in order to discover each other, exchange keys, and negotiate direct peer-to-peer
connections. Headscale does not replace the Tailscale client or its WireGuard-based
tunneling; you keep using the official Tailscale apps on every device, you just point
them at your own server (--login-server=https://...) instead of Tailscale's hosted
login.tailscale.com. Everything you know about Tailscale — device lists, ACLs, subnet
routers, exit nodes, MagicDNS — works the same way, running against infrastructure you
control.
The appeal is straightforward: Tailscale's free tier caps you at a limited number of devices, and paid tiers bill per user. A single small VPS running Headscale coordinates as many devices as you want, with no seat-based pricing, and your device-to-device control metadata never touches a third party. The actual encrypted mesh traffic between your devices is already peer-to-peer WireGuard by design (Tailscale's whole point) — Headscale's job is coordination, not proxying, which is exactly why it can run on a tiny box.
Headscale is licensed BSD-3-Clause and, as of this writing, is still pre-1.0
(the 0.2x release line). It's stable and widely run in production by self-hosters, but
the project explicitly reserves the right to break config or CLI compatibility between
minor versions until 1.0 — read the release notes before upgrading. There's also
no built-in web admin UI: everything (creating users, issuing pre-auth keys, listing
nodes) goes through the headscale CLI, run inside the server's own container. A few
community web UIs exist as separate optional projects, but this guide sticks to the
CLI, which is what ships and is guaranteed to work.
Server sizing
Headscale is light. It's a single Go binary that holds device state, public keys, and ACL policy in a small database and answers coordination requests — it does not proxy your actual VPN traffic, so its resource use doesn't scale with how much data your devices push through the mesh. ~256 MB RAM is the realistic baseline for the Headscale process itself, even with a few hundred nodes registered. A VPS with 1 vCPU and 512 MB–1 GB RAM comfortably runs Headscale plus a reverse proxy for TLS, with room to spare.
By default Headscale stores its state in SQLite, and that's genuinely fine for the vast majority of self-hosted deployments — a single file, zero extra services, easy to back up. Headscale also supports Postgres as an alternative backend for larger or higher-write deployments, but unless you're coordinating a large fleet with frequent churn, SQLite is the right default and the one this guide uses. Disk needs are minimal — 10–20 GB is comfortable and mostly consumed by the OS and Docker images, not Headscale's own data.
This makes Headscale one of the cheapest things worth self-hosting on a VPS: the smallest tier from any provider that gives you a real IPv4 address and 512 MB+ of RAM is enough.
Prepare the server
This guide assumes you've already worked through
Docker & Compose on Ubuntu — a fresh Ubuntu
26.04 box, a non-root deploy user, Docker Engine and the Compose plugin installed, and
ufw allowing SSH plus 80 and 443. If you haven't, do that first; everything below is a
container on top of that base.
Headscale listens on port 8080 internally, but that port should never be exposed to the internet directly — a reverse proxy terminates TLS on 443 and forwards to Headscale over localhost, so 8080 stays private. With the base firewall from the Docker guide (SSH, 80, 443), you already have exactly the right ports open and nothing more.
Create a working directory for the stack and a subdirectory for config:
mkdir -p ~/headscale/config && cd ~/headscale
Install Headscale
Rather than hand-write a config file from scratch, pull the official example config for the exact version you're deploying — Headscale's config format has evolved across releases, and the example shipped with a given tag is guaranteed to match that tag's expectations. This guide pins v0.29.2, the current stable release:
curl -fsSL -o config/config.yaml \
https://raw.githubusercontent.com/juanfont/headscale/v0.29.2/config-example.yaml
Now edit the handful of values that matter for a single-node deploy behind a reverse
proxy. Replace <SERVER_IP> with your VPS's public IP address — with no real domain,
sslip.io resolves <SERVER_IP>.sslip.io straight back to that IP, so it works as a
real hostname with zero DNS setup:
SERVER_IP="203.0.113.10" # replace with your VPS's public IP
DOMAIN="headscale.${SERVER_IP}.sslip.io"
sed -i \
-e "s|^server_url: .*|server_url: https://${DOMAIN}|" \
-e "s|^listen_addr: .*|listen_addr: 0.0.0.0:8080|" \
-e "s|^ base_domain: .*| base_domain: tailnet.internal|" \
config/config.yaml
A few things worth understanding about those three edits:
server_urlis the public HTTPS address clients will use to reach this server — it gets baked into every client's config, so it must be the exact URL your reverse proxy serves.listen_addr: 0.0.0.0:8080makes Headscale bind to all interfaces inside the container. This is safe because the container itself isn't reachable from the internet — only the reverse proxy on the host talks to it, over localhost, once we publish the port below.base_domainis the suffix Headscale's MagicDNS gives your nodes (my-laptop.tailnet.internal). It must be a different domain fromserver_url— Headscale refuses to start if the coordination server's hostname sits underbase_domain, since that would make its own DERP relay unreachable. Keep it a private, internal-only suffix liketailnet.internal; it needs no DNS record, because MagicDNS resolution happens over the coordination connection, not public DNS.
Now write the Compose file. Headscale ships as a single official image
(headscale/headscale) — no separate database container needed for the SQLite setup
this guide uses:
cat > docker-compose.yml <<'EOF'
services:
headscale:
image: headscale/headscale:0.29.2
container_name: headscale
restart: unless-stopped
command: serve
ports:
- "127.0.0.1:8080:8080"
volumes:
- ./config:/etc/headscale:ro
- headscale_data:/var/lib/headscale
volumes:
headscale_data:
EOF
ports: "127.0.0.1:8080:8080"binds Headscale only to localhost on the host, so it's reachable by the reverse proxy on the same box but never directly from the internet. Docker can bypassufwfor published ports, so this explicit127.0.0.1bind — not the firewall — is what keeps 8080 private.- The
headscale_datavolume (/var/lib/headscale) holds the SQLite database and the Noise protocol private key generated on first boot. Together withconfig/, this volume is your entire installation. configis mounted read-only (:ro) — Headscale only reads it; nothing inside the container needs to write there.
Start the stack and confirm it comes up healthy:
docker compose up -d
docker compose logs --tail 40 headscale
# Headscale generates keys and opens its database on first boot, so give it
# a moment — poll the health endpoint until it answers:
for i in $(seq 1 20); do
curl -fsS http://127.0.0.1:8080/health && break
sleep 2
done
A healthy server prints {"status":"pass"} (or similar) from that last curl. On first
start, Headscale generates its Noise private key and SQLite database inside the
headscale_data volume automatically — no manual key generation needed.
docker compose logs -f headscale
HTTPS + domain
Headscale must be served over HTTPS on a real, publicly resolvable hostname — Tailscale clients refuse to register against a plain-HTTP or self-signed endpoint, and the coordination protocol relies on a valid TLS connection throughout a device's lifetime, not just at login. Put a reverse proxy in front to terminate TLS and forward to Headscale on 8080.
The simplest path is
Automatic HTTPS with Caddy, which fetches and
renews Let's Encrypt certificates for you. Point your hostname
(headscale.<SERVER_IP>.sslip.io, or a real domain's A record) at the server's public
IP, then have Caddy reverse-proxy that hostname to 127.0.0.1:8080. Once DNS resolves —
immediately, for an sslip.io hostname — Caddy issues a certificate and Headscale is live
on HTTPS. (That 127.0.0.1:8080 target assumes Caddy runs on the host; if you run Caddy
as a container per that guide, give the Caddy service network_mode: host, or put
Headscale and Caddy in the same Compose file and proxy to the service name instead:
reverse_proxy headscale:8080.) No special proxy configuration is needed beyond a
standard reverse_proxy directive — Caddy forwards the long-lived and streaming
connections the coordination protocol uses without extra tuning.
The server_url gotcha. Just like the domain your proxy serves has to be exactly
what's in server_url, changing either one later means every already-registered client
needs to re-authenticate. Get the hostname right before you register your first device,
and if you ever do need to change it, edit config.yaml and restart:
docker compose up -d
First-run setup
With no web UI, administration happens entirely through the headscale CLI, run inside
the running container via docker compose exec.
Create a user (a namespace that owns devices — one per person or team is typical):
docker compose exec headscale headscale users create myteam
Issue a pre-auth key for that user — this is what a device uses to register without an
interactive login flow, similar to an invite token. Since Headscale 0.26 the
--user flag takes the user's numeric ID, not its name, so look the ID up first:
USER_ID=$(docker compose exec -T headscale headscale users list --output json | jq -r '.[] | select(.name=="myteam").id')
docker compose exec headscale headscale preauthkeys create --user "$USER_ID" --expiration 24h
Copy the key it prints. On any machine with the official Tailscale client installed, register it against your server:
tailscale up --login-server=https://headscale.<SERVER_IP>.sslip.io --authkey <PREAUTH_KEY>
Back on the server, confirm the CLI and user/key flow are working end to end:
docker compose exec headscale headscale users list
docker compose exec headscale headscale nodes list
nodes list is empty until a device actually registers with the key above — that's
expected on a fresh server. Once you run the tailscale up command from a real client,
the node appears here, and tailscale status on that client shows it connected through
your Headscale server rather than Tailscale's hosted service.
Backups
Two things make up your entire Headscale installation, and both need backing up:
- The
headscale_datavolume — holds the SQLite database (every user, device, and key) and the Noise private key generated on first boot. config/config.yaml— your server URL, domain, and DNS settings. Small, but annoying to reconstruct from memory.
Losing the Noise private key is the sharpest edge here: if it's lost, every already-
registered device loses its trust relationship with the server and has to
re-authenticate with a fresh pre-auth key. It lives only inside headscale_data, so
that volume is not optional.
Back up both with a scheduled job, copying the results off-box:
mkdir -p ~/headscale-backups
tar czf ~/headscale-backups/headscale-config-$(date +%F).tar.gz -C ~/headscale config
docker run --rm \
-v "$(docker volume ls -q --filter name=headscale_data)":/data:ro \
-v ~/headscale-backups:/backup \
alpine tar czf /backup/headscale-data-$(date +%F).tar.gz -C /data .
A backup that only lives on the same server dies with the server — get these two archives to an S3-compatible bucket or another machine, and periodically test a restore into a throwaway stack. An untested backup is a guess.
Upgrades
Headscale is pre-1.0 and moves fast, and — per the project's own notes — minor versions can carry breaking config or database changes. Treat every upgrade as deliberate, not automatic.
To upgrade: back up (above), read the release notes for the target version, bump the
pinned tag in docker-compose.yml (headscale/headscale:0.29.2 → the new version), then
pull and recreate:
docker compose pull
docker compose up -d
Headscale applies any pending database migrations automatically on start, which is exactly why a current backup before upgrading is non-negotiable — a migration changes your data in place, and there's no clean way to go back to an older version once it's run. Never skip a major version on the strength of "it'll probably migrate fine" — step through what the release notes call out, especially any noted CLI or ACL policy format changes.
Troubleshooting
A client hangs on tailscale up or reports it can't reach the login server.
Almost always a server_url mismatch, or the reverse proxy isn't actually forwarding to
Headscale. Confirm curl -fsS https://headscale.<SERVER_IP>.sslip.io/health returns a
healthy response from outside the server, and that server_url in config.yaml
exactly matches the hostname you're connecting to (scheme included).
docker compose exec headscale headscale ... fails with a connection error. The
container isn't running or hasn't finished starting. Check
docker compose ps and docker compose logs --tail 40 headscale — a common cause is a
config.yaml typo from the sed edit; re-check server_url, listen_addr, and
base_domain are valid YAML.
A device registers, but MagicDNS names don't resolve on other devices. MagicDNS
needs dns.magic_dns: true in config.yaml (already set in the official example), and
the client itself needs "Use Tailscale DNS" / MagicDNS enabled — check tailscale status
and the client's own settings, not just the server.
Pre-auth key rejected as expired or already used. Keys are single-use by default and
carry the expiration you set (--expiration 24h above). Issue a fresh one with
headscale preauthkeys create --user myteam --expiration 24h, or add --reusable if you
want a key that can register multiple devices before it expires.
Upgrade fails to start / logs show a migration error. Restore the previous
docker-compose.yml image tag and the pre-upgrade backup, then re-check the release
notes for a documented migration path before trying again — don't force a downgrade
after a migration has already partially applied.
Verification + next steps
You're done when: curl https://headscale.<SERVER_IP>.sslip.io/health returns healthy
from outside the server, docker compose exec headscale headscale users list shows the
user you created, a pre-auth key issues successfully, and — once you run the client-side
tailscale up --login-server=... step from a real device — headscale nodes list shows
it registered and tailscale status on that device confirms it's connected through your
server.
From here, register your other devices the same way (issue a pre-auth key per device, or a reusable one for a batch), and look into Headscale's ACL policy file if you need to restrict which devices can reach each other. A small VPS is genuinely enough for this — a Hetzner CX22 or the smallest Kamatera tier with 1 GB RAM has plenty of headroom. See Best VPS for self-hosting for the ranked picks.