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