SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor · Updated 2026-11-01

Headscale: self-host your own Tailscale

Run your own Tailscale control server on a VPS. Install headscale from the official .deb, set server_url and restart 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. Running that coordination role yourself changes who holds it rather than what it is able to do, so it is worth understanding what a control server can and cannot reach in this model before you treat the move as a security win on its own.

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. Cost is often what sends people looking in the first place, so it is worth reading what the hosted free plan actually covers before you take on a server, because a handful of personal machines usually fits inside it. If you are already past that ceiling, do the sum against what the paid plans cost per user and per tagged resource, because user devices are unlimited while tagged resources have separate allowances and charges. If you want a self-hosted control plane but would rather have your own client and a web interface for managing peers than a drop-in replacement for Tailscale's, NetBird on a single VPS is the alternative worth weighing. Both carry your traffic over WireGuard, so the choice turns on how you write access rules and how relays behave, and comparing NetBird with Tailscale on those points is worth doing before you install either. 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. Headscale 0.29.6 requires Tailscale client version 1.80.0 or newer.

Install headscale from the official .deb

The project publishes .deb packages on its GitHub releases page. As of October 27, 2026 the latest stable release is 0.29.6, released October 24, 2026. 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.6"
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, installs a systemd unit, and then starts the service. So headscale is already running when apt returns, and sudo systemctl is-active headscale prints active. On a fresh install the package does not enable the unit, though, so the service will not come back after a reboot until you enable it, which the restart step below does. It is also running on the shipped configuration, and that points server_url at http://127.0.0.1:8080, which is not an address any client of yours can reach. Nothing is exposed yet, because the shipped listen_addr is on loopback too, but do not join any client until you have set server_url and restarted the service, which the next two steps do.

Configure server_url before any client joins

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 public address headscale uses when building registration URLs. Clients use the control address configured with --login-server, so use the same public name with https:// in both places, never 127.0.0.1. Headscale's documentation says plain HTTP, or HTTPS on a port other than 443, is possible, but it strongly recommends HTTPS on port 443 for production, and the rest of this guide assumes it.

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.

Restart headscale and prove it is running

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

is-active prints active, is-enabled prints enabled, and the curl prints 200. The restart is the step that loads your edits. Headscale reads its configuration once, when it starts, so the process the package started keeps using the shipped server_url until you restart it. sudo systemctl enable --now headscale does not do this job: the service is already running, so the --now start changes nothing and the old value stays live. Starting at boot is the job of enable, and it is not done for you: on a fresh install the package starts the service without enabling it, so run sudo systemctl enable headscale once, as above.

If is-active prints anything other than active, or the curl does not print 200, 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 invalid required setting 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 clients must fetch the new server key before reconnecting; existing registrations remain in the database.

Put TLS in front of headscale

Clients should 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 has to forward the Upgrade and Connection headers explicitly. The headscale documentation does it with an 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 control connection never forms: new nodes cannot register, and existing nodes cannot reconnect and report online. 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's control connection to headscale. If you later enable the embedded DERP relay, it also needs 3478/udp, covered below. Port 80 is there for the ACME (automatic certificate management environment) HTTP challenge and the redirect to HTTPS. Caddy can still get a certificate with port 80 closed, because it can use the TLS-ALPN challenge on port 443, but with 80 open both challenges are available and plain http:// requests get redirected.

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 in UFW would expose nothing, since nothing listens on it from outside, and it buys nothing either. 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.

Use the public HTTPS address from server_url for --login-server. The client strips trailing slashes from its control URL; there is no exact string comparison that requires you to omit one.

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. Once the VPS itself is a node, it can also carry your other machines' internet traffic, which is the exit node setup, with the one difference that you approve the advertised route on the server with the headscale command rather than in a hosted admin console. If what you want is reach into a private network sitting behind that VPS rather than a way out to the internet, the same approval step covers advertising that subnet to the rest of your tailnet. Publishing one application from a node, rather than routing whole networks through it, is a different job again, and serve and funnel are the two ways to do it, though both lean on Tailscale's own certificate and ingress machinery, so read them as hosted-tailnet features rather than something headscale hands you.

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, replace the shipped ipv4: 198.51.100.1 and ipv6: 2001:db8::1 with your relay's public addresses, or remove both settings to use DNS, then restart headscale and open the STUN (session traversal utilities for NAT) port with sudo ufw allow 3478/udp. Those address settings override DNS, so leaving either placeholder in place sends clients to an unreachable address. 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; keep a working embedded or custom relay before doing that. Headscale refuses to start with an empty relay map, and a map containing only an unreachable relay leaves nodes that cannot connect directly unable to communicate.

From a client, tailscale netcheck prints the latency to each relay region it knows about, and tailscale status shows direct with an address or relay with a region code for active peer connections. A peer stuck on relay needs its direct path checked, including NAT and firewall rules. A peer that is direct and still slow is a different question again, and the usual answer there is MTU rather than the tunnel itself.

Why does a node show as offline?

The proxy is dropping the upgrade. Its signature is that /health returns 200 while new registrations fail or existing nodes cannot reconnect. The control connection is a POST carrying Upgrade: tailscale-control-protocol, and a proxy that does not forward it blocks both registration and the channel that reports node state. Compare your nginx configuration against the upgrade lines above, or switch to Caddy to rule the proxy out.

The public control address changed. Nodes keep dialing the address configured with --login-server; changing server_url does not update that client setting. If the old address no longer reaches your server, 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. Check the client journal too: a request reaching headscale is not guaranteed to produce a line at the default log level, so server log silence alone does not prove a DNS, firewall or proxy failure.

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; an explicit client expiry or an OIDC expiry can still apply. Tagged nodes are exempt from automatic expiry, though an administrator can expire them explicitly. Set expiry: 180d if you want a 180-day default for non-tagged registrations, but a client-requested expiry takes precedence and OIDC can override the default. Changing this setting does not retroactively change existing registrations. A node with an expiry needs re-authentication before that deadline, using sudo tailscale up --login-server https://headscale.example.com --force-reauth, and a headless server that nobody re-authenticates will fall off the network when its registration expires.

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. Version 0.29.6 also includes security updates to certificate validation and dependencies, so existing 0.29 installations should take this patch release.

FAQ

Is headscale already running right after I install the .deb?

Yes. The package starts the service as soon as it installs, on the shipped /etc/headscale/config.yaml, which listens on loopback only and sets server_url to http://127.0.0.1:8080. On a fresh install it does not enable the service, so it will not start at boot until you run sudo systemctl enable headscale. The shipped address is useless to clients, so edit server_url, listen_addr and base_domain, then run sudo systemctl restart headscale and confirm with sudo systemctl is-active headscale. It has to be restart: headscale reads the file only when it starts, and enable --now on a service that is already running enables it for boot but does not restart it, so the old values stay live. If it fails after the restart, 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 needs its control connection checked, including 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 forward the Upgrade and Connection headers explicitly with proxy_set_header, which the configuration above does through its map $http_upgrade $connection_upgrade block. Dropping those headers also prevents new nodes from registering. 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?

For this setup, yes: use a domain name and HTTPS. Certificates can also cover bare IP addresses, but 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. Plain HTTP is possible, but headscale's documentation strongly recommends HTTPS on port 443 for production, and notes that the Tailscale client assumes HTTPS on port 443 in certain situations.

Was this article useful?