Runbook

Deploying Headscale on a VPS

Stands up a self-hosted control plane on a single fresh VPS: Headscale, Headscale-UI, and Traefik running under Docker Compose, with Let's Encrypt handling TLS automatically. Run everything as a non-root sudo user over SSH unless a step says otherwise.

This is a sanitized, reusable version. Replace the placeholders before running anything: <headscale-domain> (e.g. headscale.example.com), <magicdns-domain> (e.g. ts.example.com), <admin-user> (the sudo user), <admin-email> (for Let's Encrypt), and <timezone> (e.g. America/Chicago).
Looking to add a VPS as an exit node against a Headscale server you already have running? See the Tailscale Exit Node runbook — this page covers standing up the control plane itself.

Prerequisites

Confirm these before starting — the DNS requirement especially, since skipping it causes the certificate step to fail later on.

— A fresh Ubuntu 24.04 VPS

— A domain or subdomain (e.g. <headscale-domain>) with an A record already pointing at the VPS's public IP. DNS must resolve before you start Traefik, or Let's Encrypt's HTTP-01 challenge will fail.

— A sudo-capable non-root user to SSH in as

Part A — Install Docker

Step 1 · Update the system Run on: Headscale VPS

sudo apt update && sudo apt full-upgrade -y

Step 2 · Install Docker Engine + Compose plugin Run on: Headscale VPS

sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] 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

Step 3 · Let your user run Docker without sudo Run on: Headscale VPS

sudo usermod -aG docker <admin-user>
newgrp docker
If group membership doesn't take effect immediately, disconnect and reconnect SSH.

Step 4 · Firewall Run on: Headscale VPS

sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 3478/udp
sudo ufw --force enable

Part B — Deploy the stack

Step 5 · Set variables and create directories Run on: Headscale VPS

Run this whole block as one paste — the variables must stay set in-session for Steps 6–7.

mkdir -p ~/headscale/{config,lib,letsencrypt}
cd ~/headscale
DOMAIN="<headscale-domain>"
MAGICDNS_DOMAIN="<magicdns-domain>"
EMAIL="<admin-email>"
TZ="<timezone>"
PUBLIC_IP=$(curl -4 -s ifconfig.me)
echo "Public IP: $PUBLIC_IP"

Step 6 · Write docker-compose.yml Run on: Headscale VPS

Traefik version note: pin the image to v3.6 or newer. Docker Engine 29+ raised its minimum supported API version to 1.44, and Traefik versions before 3.6.1 hardcode the old 1.24 client — causing a client version 1.24 is too old crash loop. Do not downgrade below v3.6.
cat > docker-compose.yml <
      

Step 7 · Write config/config.yaml Run on: Headscale VPS

Headscale 0.29 note: two config keys from older guides were removed — randomize_client_port (top-level) and ephemeral_node_inactivity_timeout — including either causes a FATAL crash loop. They're omitted below on purpose. If you need randomized client ports, set randomizeClientPort: true in your policy file instead.
cat > config/config.yaml <
      

Step 8 · Start the stack Run on: Headscale VPS

docker compose up -d

Step 9 · Verify the certificate issued and services are healthy Run on: Headscale VPS

docker compose ps
docker compose logs --tail 50 traefik

Look for successful certificate acquisition in the Traefik logs, and confirm headscale shows Up ... (healthy).

Quick end-to-end check:

curl -vk https://<headscale-domain>
curl -vk https://<headscale-domain>/web

Both should return HTTP/2 200.

Part C — First login and device registration

Step 10 · Generate an API key and create your user Run on: Headscale VPS

docker exec headscale headscale apikeys create
docker exec headscale headscale users create <admin-user>
Copy the printed API key immediately — it's only shown once.

Step 11 · Log into the web UI Run on: your browser

— Go to https://<headscale-domain>/web

— Settings → set Headscale URL to https://<headscale-domain>

— Paste the API key from Step 10 and save

Step 12 · Register a device Run on: client device

Install Tailscale on the client, then:

tailscale up --login-server=https://<headscale-domain>

It prints a registration URL containing an auth-id (hskey-authreq-...). Approve it — Run on: Headscale VPS

docker exec headscale headscale nodes register --user <admin-user> --key hskey-authreq-XXXXXXXXXXXX

Confirm:

docker exec headscale headscale nodes list
Registering a VPS specifically as an exit node against this server? Continue with the Tailscale Exit Node runbook from its Step 7 onward.

Optional — Restricting the web UI to tailnet-only access (firewall method)

Keeps the admin UI (/web) unreachable from the public internet — only devices already connected to your own tailnet can reach it. The core Headscale API on port 443 stays public, since it's required for client registration.

1 · Join the VPS to its own tailnet Run on: Headscale VPS

curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --login-server=https://<headscale-domain>

Approve the resulting auth-id the same way as Step 12 above.

2 · Add a second Traefik entrypoint for the UI Run on: Headscale VPS

In docker-compose.yml:

— Under the traefik service command: list, add:

- "--entrypoints.tailnet.address=:8443"

— Under traefik's ports:, add:

- "8443:8443"

— On the headscale-ui labels, change entrypoints=websecure to entrypoints=tailnet, and remove the tls / tls.certresolver lines for that router — no cert is needed, since WireGuard already encrypts the traffic.

3 · Firewall: allow port 8443 only via the tailscale interface Run on: Headscale VPS

sudo ufw allow in on tailscale0 to any port 8443 proto tcp
sudo ufw deny in on eth0 to any port 8443 proto tcp
Confirm your public interface name with ip a first if it's not eth0.

4 · Recreate containers Run on: Headscale VPS

docker compose up -d

Reach the admin UI afterward at http://<vps-tailscale-ip>:8443/web — get that IP with:

tailscale ip -4

Reference — useful ongoing commands

# list all connected devices
docker exec headscale headscale nodes list
# list all users
docker exec headscale headscale users list
# check API key status/expiration
docker exec headscale headscale apikeys list
# generate a new API key
docker exec headscale headscale apikeys create
# generate a key for unattended device registration
docker exec headscale headscale preauthkeys create --user <admin-user>
# headscale logs
docker compose logs --tail 50 headscale
# traefik / cert logs
docker compose logs --tail 50 traefik
# (re)start the stack, e.g. after a reboot
docker compose up -d

Reference — known breaking changes / gotchas

As observed on this stack (Headscale 0.29.0, Traefik v3.6, Ubuntu 24.04). Versions move fast — re-check current release notes before a fresh deploy.

Issue Cause Fix
Traefik crash-loops with client version 1.24 is too old Docker Engine 29+ requires API 1.44+; Traefik <3.6.1 hardcodes 1.24 Use traefik:v3.6 or newer
Headscale FATAL on startup re: randomize_client_port Removed as a top-level config key in Headscale 0.29 Omit it; set randomizeClientPort: true in the policy file instead if needed
Headscale WARN re: ephemeral_node_inactivity_timeout Deprecated/removed key Omit it from config.yaml
/web API test fails after reboot: "missing Bearer prefix" Browser-stored API key wasn't sent correctly (stale/whitespace/expired) Re-paste the key on the Settings page, or generate a fresh key with headscale apikeys create