Skip to content

Command Palette

Search for a command to run...

How to Deploy Headscale on a VPS

Updated Jul 2026

verified on Ubuntu 26.04 · Jul 2026

Self-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.

Before you start
  • 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_url is 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:8080 makes 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_domain is the suffix Headscale's MagicDNS gives your nodes (my-laptop.tailnet.internal). It must be a different domain from server_url — Headscale refuses to start if the coordination server's hostname sits under base_domain, since that would make its own DERP relay unreachable. Keep it a private, internal-only suffix like tailnet.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 bypass ufw for published ports, so this explicit 127.0.0.1 bind — not the firewall — is what keeps 8080 private.
  • The headscale_data volume (/var/lib/headscale) holds the SQLite database and the Noise protocol private key generated on first boot. Together with config/, this volume is your entire installation.
  • config is 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_data volume — 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.

Next steps

We use analytics cookies (Google Analytics, PostHog) to see which guides are useful. No ad networks, no cross-site tracking. See our privacy policy.