SSD Nodes Learn 8GB RAM — $66/yr
Guides Matt ConnorBy Matt Connor

Headscale: self-host your own Tailscale

Run your own Tailscale control server on a VPS. Install headscale from the official .deb, set server_url before you start it, then join your first node.

Verified Every command ran end-to-end on a fresh Ubuntu 24.04 server, July 30, 2026.

What headscale is

Headscale is a self-hosted implementation of the Tailscale control server, so the machine that coordinates your private network is a VPS you own. It is a community project and is not run by Tailscale Inc. Every machine still runs the official tailscale client, pointed at your server with one flag, --login-server.

The control server is the part that knows who belongs to the network. It gives each node an address out of 100.64.0.0/10, distributes public keys, and tells nodes where to find each other. The tunnels stay WireGuard, built node to node. Traffic between two of your machines does not pass through the headscale box, unless a direct path cannot be built and the nodes fall back to a relay.

Headscale serves one tailnet (one Tailscale network) per instance, which the project describes as suitable for personal use or a small organisation. With three or four machines, a plain WireGuard VPN on a VPS you own is less software to run and less to break. Headscale pays off when you no longer want to write a [Peer] block by hand for every new laptop. For the wider comparison of the two models, see how WireGuard and Tailscale differ.

What you need before you install

  • A VPS running Ubuntu 24.04 with a public IPv4 address and sudo access. If the server is new, work through the first ten minutes on a new VPS first.
  • A DNS A record pointing at that address. This guide uses headscale.example.com.
  • A second domain or subdomain for MagicDNS. This guide uses tailnet.example.net. It must not be the same domain as the one in server_url.
  • One client machine to join, running Linux, macOS, Windows, Android or iOS.

Install headscale from the official .deb

The project publishes .deb packages on its GitHub releases page. As of July 2026 the current release is 0.29.3. Check your architecture first, because the file name carries it.

sudo apt update
sudo apt install -y wget
dpkg --print-architecture

That prints amd64 on an ordinary x86 VPS and arm64 on an Ampere or Graviton style plan. Put the answer in the variable below.

HEADSCALE_VERSION="0.29.3"
HEADSCALE_ARCH="amd64"
wget --output-document=headscale.deb \\
  "https://github.com/juanfont/headscale/releases/download/v${HEADSCALE_VERSION}/headscale_${HEADSCALE_VERSION}_linux_${HEADSCALE_ARCH}.deb"
sudo apt install -y ./headscale.deb
headscale version

The ./ in front of the file name is required. Without it, apt looks for a package called headscale.deb in your repositories and fails.

The package creates a headscale system user, writes a default /etc/headscale/config.yaml, and installs a systemd unit. It does not start the service, and that is the correct order. The shipped configuration points server_url at http://127.0.0.1:8080, which is not an address any client of yours can reach, so a service started now would be wrong even if it came up. Running sudo systemctl is-active headscale at this point prints inactive. That is expected, not a fault.

Configure server_url before you start the service

Edit /etc/headscale/config.yaml with sudo nano /etc/headscale/config.yaml, or apply the same three changes with sed. Keep a copy of the original, because the file is long and heavily commented and it is the best reference you have for the rest of the settings.

sudo cp /etc/headscale/config.yaml /etc/headscale/config.yaml.orig
sudo sed -i 's|^server_url:.*|server_url: https://headscale.example.com|' /etc/headscale/config.yaml
sudo sed -i 's|^listen_addr:.*|listen_addr: 127.0.0.1:8080|' /etc/headscale/config.yaml
sudo sed -i 's|^  base_domain:.*|  base_domain: tailnet.example.net|' /etc/headscale/config.yaml
sudo grep -E '^(server_url|listen_addr):|^  base_domain:' /etc/headscale/config.yaml

server_url is the address headscale writes into every client registration. Clients dial that exact string forever after, so it must be the public name with https:// in front, never 127.0.0.1.

listen_addr is where the process binds. Leave it on loopback. A reverse proxy on the same box terminates TLS (transport layer security) and forwards to it, so nothing outside the server needs to reach port 8080.

base_domain is the MagicDNS suffix, the domain your nodes get names under. It must be a fully qualified domain name with no trailing dot, and it must be a different domain from the one in server_url, because the two name spaces would otherwise collide.

Leave the database section alone. The default is SQLite at /var/lib/headscale/db.sqlite, in a directory the package created and owns, and SQLite is enough for a tailnet of this size.

Start headscale and prove it is running

sudo systemctl enable --now headscale
sudo systemctl is-active headscale
curl -sS -o /dev/null -w '%{http_code}\\n' http://127.0.0.1:8080/health

is-active prints active and the curl prints 200. enable --now does both halves of the job: it starts the service and it marks it to start after a reboot.

If is-active prints failed, read the journal with sudo journalctl -u headscale -n 50 --no-pager. A failure at this stage is nearly always the configuration file, because headscale parses the whole file before it opens a socket, so a bad indent or an unknown key stops the process before anything listens. Fix the file, then sudo systemctl restart headscale. Every later configuration change needs that same restart. Clients reconnect on their own afterwards. If systemd units are new to you, running your own services and timers with systemd covers the commands used here.

Check the state files while you are in the shell:

stat -c '%U %n' /var/lib/headscale/db.sqlite /var/lib/headscale/noise_private.key

Both lines start with headscale, the unprivileged user the package created. noise_private.key is the server's identity to its clients. Keep it. If you delete it, headscale generates a new one and every node has to register again.

Put TLS in front of headscale

Clients must reach server_url over HTTPS. Caddy is the shortest route, because it requests and renews the certificate on its own.

sudo apt install -y caddy

Replace /etc/caddy/Caddyfile with the block from the headscale documentation:

headscale.example.com {
    reverse_proxy 127.0.0.1:8080 {
        header_up True-Client-IP {remote_host}
        header_up X-Real-IP {remote_host}
    }
}
sudo caddy validate --adapter caddyfile --config /etc/caddy/Caddyfile
sudo systemctl restart caddy
sudo systemctl is-active caddy

validate prints adapted config to JSON when the file parses. A warning that the file is not formatted is cosmetic. From your laptop, curl -sS -o /dev/null -w '%{http_code}\\n' https://headscale.example.com/health should also print 200. That single check proves DNS, the firewall, the certificate and the proxy are all working together.

Here is the proxy detail that costs people an evening. The Tailscale control connection is an HTTP upgrade, it is started with a POST rather than a GET, and the value of the Upgrade header is tailscale-control-protocol. Caddy passes that through with no extra configuration. nginx does not, so an nginx front end needs the upgrade map:

map $http_upgrade $connection_upgrade {
    default keep-alive;
    ''      close;
}

server {
    listen 443 ssl;
    server_name headscale.example.com;
    location / {
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_buffering off;
        proxy_pass http://127.0.0.1:8080;
    }
}

Leave those lines out and ordinary requests still succeed, which is why /health returns 200 and everything looks fine, while the long-lived control connection never forms and your nodes register and then sit offline. If you go the nginx route, Certbot on Ubuntu 24.04 with nginx covers the certificate half.

Which ports to open in UFW

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose

Port 443 carries every client conversation. Port 80 is only there for the ACME (automatic certificate management environment) HTTP challenge and the redirect to HTTPS, and Caddy needs it to get a certificate at all.

Port 8080 stays closed. listen_addr is 127.0.0.1:8080, so the proxy reaches headscale over the loopback interface and no firewall rule is involved. Opening 8080 to the internet gives clients a cleartext control channel and buys nothing. Keep in mind that most providers run a second firewall in their control panel, separate from UFW, so a port can be open on the box and still closed at the edge. UFW firewall basics on a VPS goes through the rule syntax in more detail.

Create a user and a preauth key

sudo headscale users create alice
sudo headscale users list

The headscale command is a client. It talks to the running daemon over the unix socket at /var/run/headscale/headscale.sock, which is mode 0770 and owned by the headscale group. Two things follow from that. The command fails while the service is stopped, which is the other reason the ordering in this guide matters, and it needs sudo unless you add your own account to the headscale group.

users list prints an ID next to each name. You need that number, because the key command takes a numeric user ID and not a name.

sudo headscale preauthkeys create --user 1 --expiration 24h

The key is printed once. Copy it now. A preauth key is single use and valid for one hour unless you say otherwise, so --expiration 24h is worth setting while you are still testing. Add --reusable for a key that enrolls several machines, and treat that one like a password, because anyone holding it can join your network.

Connect your first client with --login-server

On the machine you want to join:

curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --login-server https://headscale.example.com --auth-key 'hskey-auth-PASTE-YOUR-KEY-HERE'
tailscale status
tailscale ip -4

tailscale ip -4 prints the address headscale assigned, something like 100.64.0.1. Back on the server, sudo headscale nodes list shows the node with its ID, its user and its online state.

The value of --login-server must match server_url exactly, including the scheme and with no trailing slash. They are compared as strings, and a mismatch means the client registers against one address and is then told to talk to another.

A machine that was previously signed in to Tailscale's hosted service keeps that login. Run sudo tailscale logout on it first, then run tailscale up with --login-server.

If you leave out --auth-key, the client prints a URL instead. Open it and the page shows the identifier for that registration attempt, which you approve on the server:

sudo headscale auth register --user alice --auth-id PASTE-THE-ID-FROM-THE-PAGE

That form is nicer for your own laptop. Preauth keys are better for anything scripted, since no human has to be watching.

DERP, and what relays traffic when a direct path fails

DERP (designated encrypted relay for packets) is the fallback path. When two nodes cannot open a direct WireGuard connection, usually because both sit behind strict NAT (network address translation), they send packets through a relay instead. The relay holds no keys, so it cannot read your traffic. It does see which nodes are talking and how much data moves.

Be clear about what the default configuration does. Headscale ships pointing at https://controlplane.tailscale.com/derpmap/default with auto_update_enabled: true and update_frequency: 3h, so your control plane is yours while your relays are Tailscale's. For most people that is a fair trade. If it is not, run your own.

To run your own relay, set enabled: true under derp.server in config.yaml, restart headscale, and open the STUN (session traversal utilities for NAT) port with sudo ufw allow 3478/udp. The configuration file states the requirement plainly: the server_url must use https, because DERP requires TLS. Emptying the derp.urls list removes Tailscale's relays from the map, and if you do that without a working embedded relay, any pair of nodes that cannot connect directly cannot connect at all.

From a client, tailscale netcheck prints the latency to each relay region it knows about, and tailscale status marks every peer as either direct with an address or relay with a region code. A peer stuck on relay is a NAT problem, not a headscale problem.

Why does a node show as offline?

The proxy is dropping the upgrade. This is the common one, and its signature is that everything else looks healthy: /health returns 200, headscale nodes list shows the node, and the node never comes online. The control connection is a POST carrying Upgrade: tailscale-control-protocol, and a proxy that does not forward it kills the only channel that reports node state. Compare your nginx configuration against the map block above, or switch to Caddy to rule the proxy out.

server_url changed after the nodes registered. Nodes keep dialing the value they were given at registration. If you edited it, run sudo tailscale up --login-server https://headscale.example.com --force-reauth on each node.

The client is not running. On the node, sudo systemctl is-active tailscaled and sudo journalctl -u tailscaled -n 50 --no-pager. A client that cannot resolve or reach your domain logs its retries there.

The key expired. Covered in the next section.

To watch the server side while you test, run sudo journalctl -u headscale -f on the VPS and restart tailscaled on the client. A node that reaches headscale produces log lines immediately. Silence means the request is not arriving, so look at DNS, the firewall and the proxy before you look at headscale.

Key expiry, and the node that stops working weeks later

Two separate expiries exist, and mixing them up wastes time.

Preauth keys expire quickly by design. The default is one hour and one use. If tailscale up refuses the key, generate a fresh one on the server rather than editing anything on the client.

Node keys are the long lived half. The node section of config.yaml sets expiry: 0, and 0 means no default expiry: a registered node stays valid until you expire it. Tagged nodes never expire regardless. Set expiry: 180d if you want registrations to age out, and understand what you are asking for: every non-tagged node then needs sudo tailscale up --login-server https://headscale.example.com --force-reauth on that schedule, and a headless server that nobody re-authenticates will fall off the network on its own.

Do it by hand when someone loses a laptop. sudo headscale nodes list gives you the ID, then sudo headscale nodes expire -i 3 logs that node out, and sudo headscale nodes delete -i 3 removes it from the network entirely.

Backups and upgrades

/var/lib/headscale and /etc/headscale together are the whole server. Stop the service before you copy them, because SQLite may have writes in flight and a database copied under load can be inconsistent.

sudo systemctl stop headscale
sudo tar czf /root/headscale-state.tgz -C /var/lib headscale
sudo tar czf /root/headscale-config.tgz -C /etc headscale
sudo systemctl start headscale
sudo chmod 600 /root/headscale-*.tgz

Move both files off the box. They contain the private keys and every registration, so they deserve the same care as the server itself. restic backups from a VPS covers doing this on a schedule and encrypted.

Upgrades repeat the install: download the new .deb, sudo apt install ./headscale.deb, then restart and re-run the is-active and /health checks. Since 0.29 the upgrade path is strict. Skipping a minor version is blocked, and so is downgrading to an older minor version. Move one minor version at a time, take a backup before each step, and read that version's release notes first, because the same release changed ACL policy behaviour and moved several configuration keys.

FAQ

Why does headscale fail to start right after I install the .deb?

The package installs the unit but leaves the service stopped, and the default /etc/headscale/config.yaml is a template rather than a working configuration. Edit server_url, listen_addr and base_domain first, then run sudo systemctl enable --now headscale and confirm with sudo systemctl is-active headscale. If it still fails, sudo journalctl -u headscale -n 50 --no-pager names the problem, and at this stage it is nearly always a YAML error, since headscale parses the entire file before it binds a port.

Do I still install the normal Tailscale client on my machines?

Yes. Headscale replaces only the control server. Every node runs the official client from Tailscale, and you point it at your server with sudo tailscale up --login-server https://headscale.example.com. That flag exists in the standard client, so nothing needs patching or rebuilding.

Does my traffic go through the headscale server?

Usually not. Headscale coordinates the network and hands out keys and addresses, while the data path is WireGuard directly between your nodes. Traffic only takes a detour when two nodes cannot reach each other directly and fall back to a DERP relay, and with the shipped configuration those relays are Tailscale's public ones. Run tailscale status on a node to see whether a given peer is direct or on a relay.

Why does my node stay offline after it registers?

A node that appears in headscale nodes list but never goes online has usually lost its control connection at the reverse proxy. That connection is an HTTP upgrade sent as a POST with the header Upgrade: tailscale-control-protocol, and nginx drops it unless you add the map $http_upgrade $connection_upgrade block and the matching proxy_set_header lines. Caddy forwards it with no extra configuration, which makes it a quick way to test whether the proxy is at fault.

Do I need a domain name and TLS for headscale?

In practice, yes. Clients connect to whatever string you put in server_url, certificates are issued for names and not for bare IP addresses, and the configuration file states that DERP requires TLS. A domain plus Caddy takes about five minutes and gives you an HTTPS endpoint that renews itself. Running the control server over plain HTTP means every client conversation with it crosses the internet in the clear.