How To

Install Headscale on Ubuntu 26.04 / 24.04 (Self-Hosted Tailscale)

Headscale replaces the one part of Tailscale you can’t host yourself, the coordination server. On our test box it used 15.4 MB of RAM after an hour with two nodes connected and a policy loaded. Running Headscale on Ubuntu gives you a self-hosted Tailscale control server: the official clients keep working, but node keys, users and access rules stay on a machine you own.

Original content from computingforgeeks.com - post 145908

The steps below install Headscale from the official package, put it behind nginx with Let’s Encrypt, add a grants policy and MagicDNS, register nodes, and then approve routes, an exit node and the embedded DERP relay. Every core Headscale and Tailscale step ran on Ubuntu 26.04 (server) and Ubuntu 24.04 (client) with Headscale 0.29.5 and Tailscale 1.104.1 in October 2026. Certificate issuance did not run in the lab, so nginx served a lab CA certificate from the Let’s Encrypt paths.

Prerequisites

The control plane is small. nginx terminates TLS on 443 and proxies to Headscale on localhost, so nothing but the proxy faces the internet. Node traffic normally never passes through Headscale: peers talk WireGuard to each other directly, and only fall back to a DERP relay (which, once enabled, lives inside Headscale and also rides on 443) when NAT blocks the direct path. That is why the server needs so little. A mesh VPN like this is also a practical first step toward zero trust networking, because every node proves its identity to the control server and the policy, not the network it sits on, decides what it can reach; these zero trust books for network engineers cover the model behind that design.

Headscale architecture diagram: client01 reaches nginx on HTTPS 443 for control and DERP relay, nginx proxies to headscale on 127.0.0.1:8080, STUN on 3478/udp, WireGuard peer traffic on 41641/udp

Headscale needs to be reachable from every network your devices roam onto, so the server belongs on a public address, not behind your home NAT. The Tailscale client assumes HTTPS on port 443 in places, and the upstream requirements page strongly recommends sticking with it. Before you start, have these ready:

  • An Ubuntu 26.04 or 24.04 server with a public IPv4 address (dual stack is better)
  • A DNS name such as headscale.example.com with an A record pointing at that server, from any DNS provider
  • Ports 80/tcp and 443/tcp open from the internet, plus 3478/udp if you enable the embedded DERP server
  • A user with sudo rights and SSH access
  • Tailscale clients 1.80 or newer, since Headscale rejects older ones (it logs the minimum version every time it starts)

Sizing is not where this goes wrong. The coordination server only pushes small network maps when nodes join, move or change policy, so CPU and RAM stay tiny. Our server used 15.4 MB of memory after an hour with two nodes, and a 132 KB data directory. What grows is bandwidth if you turn on the embedded DERP relay, because relayed WireGuard traffic flows through this box. We ran 2 vCPU and 4 GB, which is a floor for following along, not a production recommendation. A small cloud VM with decent egress allowance is the realistic target.

Think about the devices that will join before you build anything. Laptops and phones only need the stock Tailscale app, but routers are worth planning for. A travel router that runs Tailscale can sit on hotel Wi-Fi and pull every device behind it into your tailnet, which is often simpler than installing a client on each one.

The two Ubuntu releases behave the same for Headscale itself, but the web stack around it differs, and one of those differences breaks the upstream nginx example on 24.04. The table shows what we found on each.

ItemUbuntu 26.04Ubuntu 24.04
Headscale .debInstalls and starts cleanlyInstalls and starts cleanly
nginx from the archive1.28.31.24.0
certbot from the archive4.0.02.9.0
HTTP/2 syntax in the vhosthttp2 on;listen 443 ssl http2;
Tailscale install scriptWorksWorks

Everything up to “Verify the tailnet” is the core setup. The sections after it are optional, and each one stands on its own, so skip whichever you do not need.

SectionNeeded forExtra things you need
Approve subnet routes and an exit nodeReaching a LAN behind a node, or routing internet traffic through oneA node with IP forwarding enabled
Turn on the embedded DERP serverRelaying through your own server instead of Tailscale’s public relaysPort 3478/udp open and the server’s public IP

Set reusable shell variables

The domain shows up in the Headscale config, the nginx vhost, the certificate request and every client login, so set it once. TAILNET_DOMAIN is the suffix MagicDNS gives your nodes (client01.tailnet.internal). It must not be the domain that holds your server name, for reasons the configuration step shows.

export SITE_DOMAIN="headscale.example.com"
export ADMIN_EMAIL="[email protected]"
export TAILNET_DOMAIN="tailnet.internal"

These live only in the current shell. If you reconnect, or switch to root with sudo -i, run the block again before you continue. A quick echo catches an empty value before it lands in a config file.

echo "Server: ${SITE_DOMAIN}  Tailnet: ${TAILNET_DOMAIN}  Contact: ${ADMIN_EMAIL}"

All three values should print. A blank after a label means the export did not run in this shell.

Install Headscale on Ubuntu

Upstream ships a .deb on every GitHub release, and it’s the install method they recommend because it creates the headscale system user, the data directory and a systemd unit for you. Neither Ubuntu 26.04 nor 24.04 carries a headscale package in its archive (apt-cache policy headscale lists only what you install yourself), so pull it from GitHub. The first command asks the releases API for the latest tag, so the same block keeps working when a new version ships.

sudo apt update
sudo apt install -y curl wget
export HS_VER=$(curl -sL https://api.github.com/repos/juanfont/headscale/releases/latest | grep tag_name | head -1 | sed 's/.*"v\([^"]*\)".*/\1/')
echo "Latest Headscale: ${HS_VER}"

The echo should print a bare version number without the leading v.

Latest Headscale: 0.29.5

If it prints nothing, the GitHub API rate limit is the usual cause on shared IPs. Wait a minute or set the version by hand from the Headscale releases page. With a version in hand, download the package for your CPU architecture and install it:

wget --output-document=headscale.deb "https://github.com/juanfont/headscale/releases/download/v${HS_VER}/headscale_${HS_VER}_linux_$(dpkg --print-architecture).deb"
sudo apt install -y ./headscale.deb

The package enables and starts the service straight away with its default config, which only listens on localhost, so nothing is exposed yet. Confirm the binary and the account it created.

headscale version
id headscale
systemctl is-active headscale

You should see the release you downloaded, a dedicated system account and an active service. If systemctl says failed instead, the journal names the config key it choked on.

headscale version v0.29.5
commit: 0f7d1e536bed9f01dd7161f7637af19bb0908335
build time: 2026-10-13T12:35:52Z
built with: go1.26.8 linux/amd64

uid=999(headscale) gid=986(headscale) groups=986(headscale)
active

The same three commands gave identical results on Ubuntu 24.04, where the service also came up and answered {"status":"pass"} on its health endpoint, so the package path is the same on both releases.

Configure Headscale for your domain

The config lives in /etc/headscale/config.yaml, and an annotated copy ships at /usr/share/doc/headscale/examples/config-example.yaml. For a server behind nginx, four keys change and the listener stays on 127.0.0.1:8080, because only nginx should reach it:

  • server_url: the public HTTPS URL clients log in to
  • dns.base_domain: the MagicDNS suffix for node names
  • trusted_proxies: loopback, so the real client IP from nginx is logged
  • policy.path: the access policy file you write next

Back up the stock file first, so you can diff against it after an upgrade and roll back a bad edit in one command.

sudo cp /etc/headscale/config.yaml /etc/headscale/config.yaml.orig

Change the base domain before the server URL. The packaged default is base_domain: example.com, and Headscale refuses to start when server_url sits inside it, because MagicDNS would then hijack the name clients use to reach the control server. We hit exactly that on the first restart, with server_url cannot be part of base_domain in the journal. These sed edits match the lines in the shipped file, so each one changes exactly one key.

sudo sed -i "s|^  base_domain:.*|  base_domain: ${TAILNET_DOMAIN}|" /etc/headscale/config.yaml
sudo sed -i "s|^server_url:.*|server_url: https://${SITE_DOMAIN}|" /etc/headscale/config.yaml
sudo sed -i 's|^trusted_proxies: \[\]|trusted_proxies:\n  - 127.0.0.1/32\n  - ::1/128|' /etc/headscale/config.yaml
sudo sed -i 's|^  path: ""|  path: /etc/headscale/policy.hujson|' /etc/headscale/config.yaml

Read the changed keys back rather than trusting sed, since a missed match fails silently.

sudo grep -nE '^server_url|^listen_addr|^trusted_proxies|^  - 127|^  - ::1|base_domain:|magic_dns|^  mode|^  path: /etc' /etc/headscale/config.yaml

All of these lines should be present with your values. MagicDNS is already on in the default config, and the policy mode is file.

13:server_url: https://headscale.example.com
19:listen_addr: 127.0.0.1:8080
47:trusted_proxies:
48:  - 127.0.0.1/32
49:  - ::1/128
299:  mode: file
301:  path: /etc/headscale/policy.hujson
325:  magic_dns: true
332:  base_domain: tailnet.internal

Do not restart yet. With policy.path set, Headscale won’t start until that file exists, and it says so plainly: loading policy: open /etc/headscale/policy.hujson: no such file or directory. The next step creates it.

Write an access policy

Without a policy file, Headscale lets every node talk to every other node on every port. That is fine for one person’s laptops and wrong the moment a second user joins. Headscale reads the same huJSON format as Tailscale and supports both legacy ACLs and the newer grants syntax. Upstream recommends grants, because Tailscale has stopped adding features to ACLs, and the Headscale policy reference lists the few Tailscale features it does not implement. Open the file.

sudo vim /etc/headscale/policy.hujson

We ran the policy below. Admins reach everything, including exit nodes; every other user reaches only their own devices, plus HTTPS and ping on servers tagged tag:server.

{
  "groups": {
    "group:admins": ["josphat@"]
  },
  "tagOwners": {
    "tag:server": ["group:admins"]
  },
  "grants": [
    // Admins reach every node and can use exit nodes
    {
      "src": ["group:admins"],
      "dst": ["*", "autogroup:internet"],
      "ip": ["*"]
    },
    // Everyone else reaches their own devices
    {
      "src": ["autogroup:member"],
      "dst": ["autogroup:self"],
      "ip": ["*"]
    },
    // and only HTTPS plus ping on tagged servers
    {
      "src": ["autogroup:member"],
      "dst": ["tag:server"],
      "ip": ["tcp:443", "icmp:*"]
    }
  ]
}

Replace josphat@ with the Headscale user you will create for yourself. The trailing @ is how the policy refers to a user. The service reads this file as the headscale user, so give that group read access and start the service.

sudo chown root:headscale /etc/headscale/policy.hujson
sudo chmod 0640 /etc/headscale/policy.hujson
sudo systemctl restart headscale
sudo headscale policy check --file /etc/headscale/policy.hujson

A valid file prints one line. The check talks to the running daemon over its unix socket, so if the service failed to start you’ll get context deadline exceeded instead, and the journal tells you why.

Policy is valid

Later policy edits do not need a restart. sudo systemctl reload headscale sends SIGHUP, and the journal logs Received SIGHUP, reloading ACL policy so you know it took. The health endpoint should answer on localhost now.

curl -s http://127.0.0.1:8080/health

The reply is {"status":"pass"}. The journal also carries one warning from here on, listening without TLS but ServerURL does not start with http://, which is expected: nginx handles TLS in the next step.

Put nginx and Let’s Encrypt in front

The proxy has one hard requirement, WebSocket support for a protocol that is not quite WebSocket. Tailscale clients upgrade the control connection with a POST and send Upgrade: tailscale-control-protocol, so a proxy that only passes standard WebSocket upgrades breaks registration. That is also why Cloudflare’s proxy and tunnels cannot sit in front of Headscale. Install nginx and certbot with its nginx plugin.

sudo apt install -y nginx certbot python3-certbot-nginx

Open the firewall before requesting the certificate, because the HTTP-01 challenge arrives on port 80. Allow SSH first, because enabling UFW without that rule drops your session and leaves you to recover from the provider’s console. The OpenSSH profile only covers port 22, so if your sshd listens elsewhere, allow that port instead (for example sudo ufw allow 2222/tcp). Opening 41641/udp is optional but helps nodes behind strict NAT reach the server directly instead of through a relay.

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 41641/udp
sudo ufw --force enable
sudo ufw status

The status should list OpenSSH, 80/tcp, 443/tcp and 41641/udp as ALLOW for both IPv4 and IPv6. Our UFW firewall guide covers rate limiting and logging if you want more than the basics. Request the certificate next. The nginx authenticator answers the challenge through the running nginx, and the deploy hook reloads nginx after every renewal so the new certificate is actually served.

sudo certbot certonly --nginx -d "${SITE_DOMAIN}" -m "${ADMIN_EMAIL}" --agree-tos --non-interactive --deploy-hook "systemctl reload nginx"

Certbot stores the files under /etc/letsencrypt/live/ plus your domain, and that’s where the vhost below expects them. We did not run this command: our lab has no public DNS, so we placed a certificate from a lab CA at exactly those paths and served the same vhost with it. If the request fails, the A record is not resolving to this server yet or port 80 is blocked upstream (cloud security group, router), and the troubleshooting section covers both. With the certificate in place, create the vhost.

sudo vim /etc/nginx/sites-available/headscale.conf

The vhost below is the upstream reverse proxy example with the certificate paths filled in. It answers the Tailscale captive portal probe on /generate_204, redirects everything else on port 80, and forwards the real client IP in the headers Headscale trusts. Keep SITE_DOMAIN_HERE as it is; the sed command after it swaps in your domain.

# headscale
upstream headscale {
  zone upstreams 64K;
  server 127.0.0.1:8080 max_fails=1 fail_timeout=5s;
  keepalive 2;
}

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

# http
server {
  listen 80;
  listen [::]:80;

  server_name SITE_DOMAIN_HERE;

  # Tailscale captive portal detection
  location = /generate_204 {
    return 204;
  }

  location / {
    return 301 https://$server_name$request_uri;
  }
}

# https
server {
  listen 443 ssl;
  listen [::]:443 ssl;
  http2 on;

  server_name SITE_DOMAIN_HERE;

  ssl_certificate /etc/letsencrypt/live/SITE_DOMAIN_HERE/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/SITE_DOMAIN_HERE/privkey.pem;

  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 True-Client-IP $remote_addr;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_buffering off;
    proxy_pass http://headscale;
  }
}

Substitute the domain and enable the site. The symlink is what makes nginx load it, because Ubuntu’s nginx only reads sites-enabled. Skip it and the test below still passes, while the domain serves the stock nginx page on port 80 and nothing at all on 443.

sudo sed -i "s/SITE_DOMAIN_HERE/${SITE_DOMAIN}/g" /etc/nginx/sites-available/headscale.conf
sudo ln -s /etc/nginx/sites-available/headscale.conf /etc/nginx/sites-enabled/

On Ubuntu 24.04 there’s one more edit. Its nginx predates the release that introduced the standalone http2 directive, so nginx -t fails with unknown directive "http2". Move HTTP/2 onto the listen lines instead, and skip this on 26.04.

sudo sed -i -e '/^  http2 on;/d' -e 's/^  listen 443 ssl;/  listen 443 ssl http2;/' -e 's/^  listen \[::\]:443 ssl;/  listen [::]:443 ssl http2;/' /etc/nginx/sites-available/headscale.conf

Test the config before reloading, so a typo never takes down a running proxy.

sudo nginx -t
sudo systemctl reload nginx

Both releases report a clean test once the right HTTP/2 syntax is in place.

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Now check all three paths a client uses. Give the reload a second first. We curled immediately after systemctl reload and got a 404 from the old worker, then the expected answers a moment later, so a single odd result right after a reload is not a config problem.

curl -sI "http://${SITE_DOMAIN}/generate_204" | head -1
curl -sI "http://${SITE_DOMAIN}/health" | grep -E 'HTTP|Location'
curl -s "https://${SITE_DOMAIN}/health"

A healthy proxy returns 204 for the captive portal probe, a 301 to HTTPS for everything else on port 80, and the Headscale health JSON over TLS.

HTTP/1.1 204 No Content
HTTP/1.1 301 Moved Permanently
Location: https://headscale.example.com/health
{"status":"pass"}

A 502 on the last line means nginx is up but Headscale is not, so check journalctl -u headscale. A certificate error means the vhost points at the wrong files. The service, version and health check together look like this on the server.

Headscale service running on Ubuntu 26.04 with version and health check output

If you’d rather use a different proxy, the nginx install guide covers the web server itself in more depth, and upstream documents Apache and Caddy equivalents of this vhost.

Alternative: let Headscale request its own certificate

Headscale has a built-in ACME client, which is the shorter path when this server runs nothing else on 80 and 443. We did not test it. Use it instead of nginx, not alongside it: skip the nginx section entirely (or remove nginx), because both want ports 80 and 443. Set tls_letsencrypt_hostname to your domain, set listen_addr to 0.0.0.0:443, and leave tls_cert_path empty. Headscale then answers the HTTP-01 challenge on port 80 itself and renews when 30 days or less remain. The packaged systemd unit already grants CAP_NET_BIND_SERVICE, so the unprivileged headscale user can bind both ports. What you give up is the port itself: nothing else on the box can serve 443, and certificate problems surface in the Headscale log instead of certbot’s. We’d use nginx for any server that might ever carry a second site.

Create users and register nodes

Every node belongs either to a Headscale user or to a tag, and the policy above is written in terms of both. Create a user for yourself and one for a second person, so the policy has something to separate.

sudo headscale users create josphat
sudo headscale users create mary
sudo headscale users list

Note the ID column. Current Headscale takes the numeric user ID, not the name, when it creates keys, and passing the name fails with a ParseUint error, as the last section shows.

ID | Name | Username | Email | Created
1  |      | josphat  |       | 2026-10-16 12:59:50
2  |      | mary     |       | 2026-10-16 12:59:50

Users are cheap, so create one per person rather than sharing an account. The policy can only tell people apart if their devices belong to different users.

Join the server to its own tailnet

The checks later in this guide ping the Headscale server over the tailnet and use it as an exit node, so join it first. A preauth key registers a node with no browser step, which is what you want for servers and scripted installs. Keys default to one use and a one-hour lifetime. Adding --tags tag:server makes the tag the owner instead of a person, so the policy’s tag:server rule applies to this machine. Install the Tailscale client on the server with the official script, which detects the distro and adds Tailscale’s apt repository.

curl -fsSL https://tailscale.com/install.sh | sh
tailscale version | head -1

The script finishes with “Installation complete!”, and the version line confirms the client is in place.

1.104.1

Create a tagged key for user 1, then point the server’s client at its own Headscale. Paste the key the first command printed into AUTH_KEY, and treat it like a password until it expires.

sudo headscale preauthkeys create --user 1 --tags tag:server --expiration 1h
export AUTH_KEY="hskey-auth-EXAMPLEonly-replaceWithTheKeyYourServerPrinted"
sudo tailscale up --login-server "https://${SITE_DOMAIN}" --authkey "${AUTH_KEY}"
tailscale ip -4

Registration took 3.8 seconds here. Note the address tailscale ip -4 prints, because the client checks later use it; ours was the first address in the pool.

100.64.0.1

The node list on the server should show the machine owned by tagged-devices with tag:server, online and not expired.

sudo headscale nodes list

Ours read like this, with the key columns abbreviated by Headscale itself:

ID | Hostname    | Name        | MachineKey | NodeKey | User           | Tags       | IP addresses      | Ephemeral | Last seen           | Expiration | Connected | Expired
1  | headscale01 | headscale01 | [sMLMj]    | [fcFZT] | tagged-devices | tag:server | 100.64.0.1        | false     | 2026-10-16 13:00:39 | N/A        | online    | no
   |             |             |            |         |                |            | fd7a:115c:a1e0::1 |           |                     |            |           |

A server that never joins still coordinates the tailnet perfectly well. You only lose the ability to reach it by tailnet name and to use it as a router, and you would then run the later checks against another node.

Register a client with a preauth key

Clients use the same flow without the tag, so the device belongs to a person and the per-user grants apply. Add --reusable and a longer --expiration when you are enrolling a batch of machines.

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

The command prints the key and nothing else. Treat it like a password until it expires, because anyone holding it can join a device as that user. A one-hour single-use key limits the damage if it leaks into a chat log or a ticket.

hskey-auth-EXAMPLEonly-replaceWithTheKeyYourServerPrinted

On the device you’re adding, install the Tailscale client with the same script. Our Tailscale on Ubuntu guide walks through the repository route by hand if you prefer that.

curl -fsSL https://tailscale.com/install.sh | sh

Point the client at your server instead of Tailscale’s, passing the key you just created. Exporting it first keeps the key in one place if you register several commands in a row. Set TAILNET_DOMAIN here too, with the same value as base_domain on the server, because the MagicDNS check later runs on this client.

export SITE_DOMAIN="headscale.example.com"
export TAILNET_DOMAIN="tailnet.internal"
export AUTH_KEY="hskey-auth-EXAMPLEonly-replaceWithTheKeyYourServerPrinted"
sudo tailscale up --login-server "https://${SITE_DOMAIN}" --authkey "${AUTH_KEY}"

Registration took 2.7 seconds on our Ubuntu 24.04 client and printed nothing, which is success. tailscale status confirms it.

100.64.0.2  client01     josphat         linux  -
100.64.0.1  headscale01  tagged-devices  linux  -

The client got the next free address in 100.64.0.0/10, and the server shows up as a peer owned by tagged-devices because it joined with the tagged key in the previous step.

Register a node through the browser

Without a key, tailscale up prints a URL and waits. Run it on a device that has not joined yet, or after sudo tailscale logout on one that has, because a node that is already logged in prints nothing. This suits a laptop or phone where someone is sitting in front of it, and the Android, iOS, macOS and Windows apps all have a custom login server setting that leads to the same flow.

sudo tailscale up --login-server "https://${SITE_DOMAIN}"

The client prints a registration link on your Headscale server and blocks until someone approves it.

To authenticate, visit:

	https://headscale.example.com/register/hskey-authreq--V8uQotgQmCA0KMa1zx0rAY3

Opening it shows the exact approval command, auth ID included. Nobody approves anything in the browser. The page only hands you the command to run on the server, so a stranger who opens the link gains nothing.

Headscale node registration page showing the headscale auth register command

Run it on the Headscale server with the user who should own the device. Here the user name works, unlike with preauth keys.

sudo headscale auth register --auth-id hskey-authreq--V8uQotgQmCA0KMa1zx0rAY3 --user mary

Headscale confirms the registration, and the waiting tailscale up on the client returns Success. at the same moment.

Node client01-1 registered

The -1 suffix is worth understanding. We re-registered the same machine as mary after logging it out as josphat, and tailscale logout only expires a node. It does not delete it. Headscale kept the old client01 entry and gave the new one a unique name. Clean up stale entries with sudo headscale nodes delete --identifier ID --force, using the ID from headscale nodes list.

Verify the tailnet and the policy

Treat this section as the checkpoint for the core setup, so do not move on to routes or DERP until every check here passes. List the nodes on the server first.

sudo headscale nodes list

Each node should read online and Expired no, with one IPv4 and one IPv6 address. A node that reads offline with an expiration in the past was logged out, not deleted.

headscale nodes list and list-routes showing approved exit node and subnet route

From a client, test both connectivity and MagicDNS. Set HS_NODE_IP to the address tailscale ip -4 printed on the server, since yours may differ from ours. tailscale ping also tells you whether the path is direct or relayed, which matters later for DERP.

export HS_NODE_IP="100.64.0.1"
tailscale ping -c 3 headscale01
ping -c 3 "headscale01.${TAILNET_DOMAIN}"

On our LAN the path was direct (the via address is the peer’s real IP and WireGuard port) and the MagicDNS name resolved to the tailnet address.

pong from headscale01 (100.64.0.1) via 10.0.1.50:41641 in 1ms
PING headscale01.tailnet.internal (100.64.0.1) 56(84) bytes of data.
64 bytes from headscale01.tailnet.internal (100.64.0.1): icmp_seq=1 ttl=64 time=3.81 ms
64 bytes from headscale01.tailnet.internal (100.64.0.1): icmp_seq=2 ttl=64 time=1.53 ms
64 bytes from headscale01.tailnet.internal (100.64.0.1): icmp_seq=3 ttl=64 time=1.26 ms

If the name does not resolve, check resolvectl status tailscale0: it should list 100.100.100.100 as the DNS server and your tailnet domain as the search domain. To prove the policy, we ran the same checks as mary, who only gets HTTPS and ping on tagged servers. These bash one-liners open a TCP connection without needing extra tools.

ping -c 2 -W 2 headscale01
curl -s -o /dev/null -w "%{http_code}\n" --resolve "${SITE_DOMAIN}:443:${HS_NODE_IP}" "https://${SITE_DOMAIN}/health"
timeout 5 bash -c "echo > /dev/tcp/${HS_NODE_IP}/22" && echo "ssh port open" || echo "ssh port blocked"
timeout 5 bash -c "echo > /dev/tcp/${HS_NODE_IP}/80" && echo "80 open" || echo "80 blocked"

Ping and HTTPS went through, SSH and port 80 were dropped, which is exactly what the grants say.

64 bytes from headscale01.tailnet.internal (100.64.0.1): icmp_seq=1 ttl=64 time=4.65 ms
64 bytes from headscale01.tailnet.internal (100.64.0.1): icmp_seq=2 ttl=64 time=1.54 ms
200
ssh port blocked
80 blocked

The same SSH check from the same machine registered as josphat, who sits in group:admins, printed ssh port open. The core setup is done at this point: a TLS-fronted control server, users, a policy and working MagicDNS. The next two sections are optional.

Approve subnet routes and an exit node

A subnet router lets tailnet devices reach a network where you can’t install Tailscale, such as printers or a NAS on an office LAN. An exit node sends a device’s internet traffic out through another node. Both need double opt-in: the node advertises, and you approve on the server. On the node that will route, enable forwarding first, because without it the routes get approved and packets still die on that node.

echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-tailscale.conf
echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf
sudo sysctl -p /etc/sysctl.d/99-tailscale.conf

Advertise the LAN behind the node and offer it as an exit node. We used our Headscale server as the router and a lab subnet of 10.99.0.0/24 on a dummy interface. Use your real LAN CIDR here.

sudo tailscale set --advertise-routes=10.99.0.0/24 --advertise-exit-node

On the server, the advertised routes show up as available but not approved. An exit node advertises as the two default routes.

sudo headscale nodes list-routes

Nothing is approved or serving yet, so no client can use them. That second opt-in is deliberate, because a compromised node could otherwise announce a default route and pull every client’s traffic through itself.

ID | Hostname    | Approved | Available    | Serving (Primary)
1  | headscale01 |          | 0.0.0.0/0    |
   |             |          | 10.99.0.0/24 |
   |             |          | ::/0         |

Approve the subnet and both default routes for that node ID. If you later want routes from tagged routers approved automatically, the policy’s autoApprovers section does that.

sudo headscale nodes approve-routes --identifier 1 --routes 10.99.0.0/24,0.0.0.0/0,::/0

Headscale answers Node updated, and list-routes now shows all three under Approved and Serving. Clients don’t use advertised subnets until they opt in, so join or update the client with --accept-routes, then reach the subnet and switch on the exit node.

sudo tailscale set --accept-routes
ping -c 3 10.99.0.1
sudo tailscale set --exit-node=headscale01
ip route get 1.1.1.1

Our ping target was the router’s own address on that subnet, which proves the route is installed but not that forwarding works. On a real LAN, ping another host behind the router (a printer or NAS) instead. With the exit node on, internet-bound traffic leaves through tailscale0 in Tailscale’s routing table 52.

64 bytes from 10.99.0.1: icmp_seq=1 ttl=64 time=3.84 ms
64 bytes from 10.99.0.1: icmp_seq=2 ttl=64 time=1.59 ms
64 bytes from 10.99.0.1: icmp_seq=3 ttl=64 time=1.19 ms
1.1.1.1 dev tailscale0 table 52 src 100.64.0.4 uid 1000
    cache

tracepath showed the exit node’s tailnet address as the first hop, and HTTPS to github.com still returned 200 through it, which does prove that forwarding on the router works. Turn the exit node off again with sudo tailscale set --exit-node=. Remember the policy decides who may use it: in ours only group:admins has autogroup:internet.

A GL.iNet router makes a good always-on exit node at home, because it stays on when your laptop is closed and costs a few watts. Our GL.iNet exit node walkthrough covers that setup.

On an office network the firewall is often the better subnet router, since it already sits on every VLAN you want to expose. pfSense users can do that part on the firewall by joining pfSense to the Headscale mesh.

Turn on the embedded DERP server

When two nodes can’t punch a direct WireGuard path through NAT, they relay through DERP. By default Headscale hands out Tailscale’s public DERP map, so relayed traffic (still end-to-end encrypted) crosses Tailscale’s servers. The embedded DERP server keeps that on your box. It costs you bandwidth and one more open port, which is the trade to weigh. This section needs the server’s public IPv4 address, which curl -4 ifconfig.me prints from the server itself.

export HOST_IP="10.0.1.50"

Back up the config, then enable the DERP server and set its advertised address. The IPv6 value in the default is a documentation placeholder, so the last edit comments it out; set a real address there only if the server has public IPv6. DERP itself rides on your existing 443 vhost, so nginx needs no change.

sudo cp /etc/headscale/config.yaml /etc/headscale/config.yaml.pre-derp
sudo sed -i '/^derp:/,/^  urls:/ s/^    enabled: false/    enabled: true/' /etc/headscale/config.yaml
sudo sed -i "/^derp:/,/^  urls:/ s/^    ipv4: .*/    ipv4: ${HOST_IP}/" /etc/headscale/config.yaml
sudo sed -i '/^derp:/,/^  urls:/ s/^    ipv6: .*/    # ipv6: 2001:db8::1/' /etc/headscale/config.yaml

Open STUN, restart, and confirm Headscale listens on UDP 3478.

sudo ufw allow 3478/udp
sudo systemctl restart headscale
sudo ss -ulnp | grep 3478

The socket belongs to the headscale process, and the journal logs stun server started at [::]:3478 plus a DERP region with ID 999.

UNCONN 0      0                       *:3478             *:*    users:(("headscale",pid=34225,fd=12))

Clients pick up the new region with their next map update. Run tailscale netcheck on a client and look for the nearest DERP.

tailscale netcheck | grep -E "UDP|Nearest|headscale:|nai:"

Our client chose the embedded relay at 300 microseconds over Tailscale’s nearest public one at 15.9 ms. The public regions stay in the map as fallback because the default derp.urls list is untouched; empty it if you want your server to be the only relay. The grep keeps the excerpt to the lines that matter; drop it to see every region.

	* UDP: true
	* Nearest DERP: Headscale Embedded DERP
		- headscale: 300µs   (Headscale Embedded DERP)
		- nai: 15.9ms  (Nairobi)

On a LAN everything goes direct, so a relay test needs the direct path gone. We dropped every UDP packet between client and server except STUN, restarted the client, and pinged again. Run this on a client to repeat the test, with HOST_IP exported there as the server’s address, and delete the rules afterwards.

sudo iptables -I OUTPUT -d "${HOST_IP}" -p udp ! --dport 3478 -j DROP
sudo iptables -I INPUT -s "${HOST_IP}" -p udp ! --sport 3478 -j DROP
sudo tailscale down; sudo tailscale up --login-server "https://${SITE_DOMAIN}" --accept-routes
tailscale ping -c 3 headscale01
sudo iptables -D OUTPUT -d "${HOST_IP}" -p udp ! --dport 3478 -j DROP
sudo iptables -D INPUT -s "${HOST_IP}" -p udp ! --sport 3478 -j DROP

With UDP blocked, every pong came back via DERP(headscale), our own relay tunnelled through nginx on 443, at 2 ms. Once the rules were deleted the next ping went direct again.

Tailscale client on Ubuntu 24.04 joined to Headscale, pinging the subnet route and the embedded DERP relay

If you also want a web dashboard over all of this, the Headscale with a UI in Docker guide adds one on top of a server like this one.

Back up and upgrade Headscale

Everything that matters lives in two directories, and our server held exactly these files:

  • /etc/headscale/config.yaml and policy.hujson
  • /var/lib/headscale/db.sqlite plus its -wal and -shm files
  • /var/lib/headscale/noise_private.key
  • /var/lib/headscale/derp_server_private.key once DERP is on

Lose the Noise key and every client has to re-register, because it is the server identity they pinned at login. Stop the service so the database is not mid-write, copy both, and start it again.

sudo systemctl stop headscale
TIMESTAMP=$(date +%Y%m%d%H%M%S)
sudo cp -aR /etc/headscale "/etc/headscale.backup-${TIMESTAMP}"
sudo cp -aR /var/lib/headscale "/var/lib/headscale.backup-${TIMESTAMP}"
sudo systemctl start headscale

The stop lasted a couple of seconds on our server, and the copies of both directories totalled well under a megabyte, so there’s no reason not to run this before every change. Copy the backup directories off the box too; a backup on the same disk doesn’t survive a dead disk.

Upgrades have one rule that bites people coming from old guides: upstream requires going through every minor release in order (0.27 to 0.28 to 0.29), on the latest patch of each, because each one runs database migrations. A server still on 0.22 from an older install has seven stops to make. Read each release’s notes for breaking changes, take the backup above, then install the next minor with an explicit version. /etc/headscale/config.yaml is a dpkg conffile, so when a new package ships a changed default config, dpkg asks whether to replace yours even with -y. Answer N to keep your file; answering Y restores base_domain: example.com and the service stops starting. The force-confold option below answers that for you.

export HS_VER="0.28.0" # pin: next minor on your upgrade path #https://github.com/juanfont/headscale/releases
wget --output-document=headscale.deb "https://github.com/juanfont/headscale/releases/download/v${HS_VER}/headscale_${HS_VER}_linux_$(dpkg --print-architecture).deb"
sudo apt install -y -o Dpkg::Options::=--force-confold ./headscale.deb
sudo diff /usr/share/doc/headscale/examples/config-example.yaml /etc/headscale/config.yaml

The diff shows options a new release added or renamed, which is where upgrades usually break. We installed the current release fresh here and didn’t run a cross-version upgrade on this server, so treat the release notes as the authority for each hop. The CLI changed along the way too, which is why commands copied from older posts fail.

TaskOlder guidesCurrent Headscale
Create a preauth keyheadscale --user NAME preauthkeys createheadscale preauthkeys create --user ID
Approve a browser loginheadscale nodes register --user NAME --key nodekey:...headscale auth register --auth-id ID --user NAME
List routesheadscale routes listheadscale nodes list-routes
Enable a routeheadscale routes enable -r IDheadscale nodes approve-routes --identifier ID --routes CIDR
Access rules"acls" section"grants" (ACLs still load, upstream calls them legacy)

nodes register still runs but prints a deprecation notice pointing at auth register, while the whole routes command group is gone. Scripts that call either need updating before you upgrade.

Fix common Headscale errors

The first five errors below came up on our servers while writing this guide, with the exact message from the journal or the terminal. Each heading is the text you will search for. The certificate entry at the end is guidance, not a reproduced failure, because issuance never ran in our lab.

Error: “server_url cannot be part of base_domain”

The full message is server_url cannot be part of base_domain in a way that could make the DERP and headscale server unreachable, and the service exits with status 1. It appears when the server name sits inside the MagicDNS domain, as with the packaged default base_domain: example.com and a server at headscale.example.com. Set base_domain to a separate suffix such as tailnet.internal and restart.

Error: “loading policy: open /etc/headscale/policy.hujson: no such file or directory”

policy.path points at a file that doesn’t exist, and Headscale won’t start without it. Create the policy file, give the headscale group read access, then restart. An empty {} is a valid allow-all policy if you just need the service up.

Error: unknown directive “http2” in /etc/nginx/sites-enabled/headscale.conf

This is nginx on Ubuntu 24.04 rejecting the upstream vhost, because the standalone http2 directive only arrived in nginx 1.25.1. Delete the http2 on; line and add http2 to both listen 443 ssl lines, as the nginx step shows, then run sudo nginx -t again.

Error: invalid argument “josphat” for “-u, –user” flag: strconv.ParseUint

preauthkeys create now expects the numeric user ID. Look it up with sudo headscale users list and pass that, for example --user 1.

Error: unknown command “routes” for “headscale”

Route management moved under nodes. Use headscale nodes list-routes to see advertised routes and headscale nodes approve-routes to approve them.

Certbot can’t get a certificate

We did not reproduce this one. With HTTP-01, a failed request almost always means Let’s Encrypt couldn’t reach port 80 on the name you asked for. Check that dig +short headscale.example.com returns this server’s public IP and that port 80 is open in the cloud firewall as well as UFW. If the server has no public port 80 at all, use a DNS-01 plugin for your DNS provider instead (python3-certbot-dns-cloudflare, python3-certbot-dns-route53 and others are in the archive). A failure there usually means the API token lacks permission to edit the zone.

If you started from the older version of this guide, the server you have probably still works. Plan the minor-by-minor upgrade and swap in the new commands from the table above before something forces the move. For a fresh build, the setup above, with nginx in front and grants in the policy from day one, is the one we’d run.

Keep reading

Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) Ubuntu Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 Security UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 PHP2Shell (CVE-2026-109618, CVE-2026-109619): Check and Patch PHP Php PHP2Shell (CVE-2026-109618, CVE-2026-109619): Check and Patch PHP Upgrade a Single Package on Ubuntu / Debian with apt Debian Upgrade a Single Package on Ubuntu / Debian with apt APT Cheat Sheet: apt and apt-get Commands for Ubuntu/Debian Cheat Sheets APT Cheat Sheet: apt and apt-get Commands for Ubuntu/Debian Install CiviCRM Relationship Management System on Debian | Ubuntu Debian Install CiviCRM Relationship Management System on Debian | Ubuntu

4 thoughts on “Install Headscale on Ubuntu 26.04 / 24.04 (Self-Hosted Tailscale)”

Leave a Comment

Press ESC to close