Use case · Mesh VPN control server

Headscale self-hosted on a dedicated server: install, config.yaml, ports and an nginx front end

A self-hosted Headscale server is the project's .deb package, a config.yaml in /etc/headscale and a SQLite database in /var/lib/headscale, answering Tailscale clients over HTTPS on port 443. Headscale is a BSD-3-Clause reimplementation of the Tailscale control server: it exchanges the nodes' WireGuard public keys, assigns their tailnet addresses and publishes their routes, while node-to-node traffic goes direct where it can and through a DERP relay where it cannot. Below: install, the config keys that matter, ports, nginx, users and OIDC, backups and upgrades.

See the machines

What Headscale is, and what it is not

Headscale replaces one component: the coordination server. The devices still run the Tailscale client, and the project tracks it closely; its client page says it aims to support the last 10 releases of the Tailscale client. Linux, the BSDs, Android, Windows, macOS, iOS and tvOS are listed as supported, the last four with extra steps described on the /windows and /apple pages your own Headscale serves.

The scope is deliberately narrow. The documentation describes "a single Tailscale network (tailnet), suitable for a personal use, or a small open-source organisation". The code is under the BSD 3-Clause licence, copyright Juan Font, and the project is maintained by Kristoffer Dalby and Juan Font.

On the relationship, the README's disclaimer reads: "This project is not associated with Tailscale Inc." It adds that one active maintainer is employed by Tailscale and may spend work hours on Headscale, with his contributions reviewed by the other maintainers.

Sizing the machine

The documentation publishes no CPU or memory figure. Its scaling answer explains where the work goes: Headscale recalculates a map of the whole network for every node whenever something changes, so load follows churn more than node count. In the FAQ's words, it "can likely handle 100s of devices (maybe more), if there is little to no change happening in the network", while many nodes changing often keep it busy. SQLite is the recommended database; PostgreSQL is still supported but described as being in maintenance mode.

That makes it a one-service box. Our dedicated server range names a VPN endpoint as the kind of job a Moonshot cartridge carries: one service, its own machine, root, an unmetered port. A single-socket server makes sense when the same host also runs your identity provider. The requirements page asks for a public IP and recommends dual stack; IPv6 is in no default build here but is added at checkout at no charge, as individual addresses. Do not also enrol this machine in the tailnet: the FAQ says running Headscale on a node that is part of the tailnet "might work, but it is not supported", with subnet routers, relays and MagicDNS named as trouble spots.

Headscale server ports

Only what clients need goes public. The rows below follow the requirements page; the 127.0.0.1 listener on 8080 is the reverse-proxy layout used on this page.

PortPublicUsed for
tcp/443YesHTTPS for Tailscale clients, and the embedded DERP relay when enabled
tcp/80YesLet's Encrypt HTTP-01 when Headscale gets its own certificate; here, nginx's redirect and the /generate_204 captive-portal check
udp/3478Yes, with the embedded DERP enabled (as in the config below)STUN for the embedded DERP server
tcp/50443Only if usedgRPC remote control of Headscale; cannot be proxied
tcp/9090NoMetrics and the debug endpoint
tcp/8080No (127.0.0.1)Headscale behind nginx

Keep the client side on 443. The docs note that the Tailscale client assumes HTTPS on port 443 in certain situations and strongly recommend it for production.

Install from the .deb

The project recommends its DEB package on Ubuntu 22.04 or newer and Debian 12 or newer: it creates the headscale system user, installs a default config and ships a hardened systemd unit. The firewall goes up first, with IPv6 rules enabled before any allow, so every rule gets its IPv6 twin.

# Firewall first: IPv6 rules on, then SSH and web only
sudo sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
sudo ufw allow OpenSSH
sudo ufw allow 80,443/tcp
sudo ufw --force enable

# The project's .deb (take the newest version from the releases page)
HEADSCALE_VERSION="0.29.5"
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 ./headscale.deb nginx
sudo systemctl status headscale --no-pager

The package starts the service straight away on the example config, whose listeners are all on 127.0.0.1, so nothing is reachable from outside yet. Version 0.29.5 was the newest release on 23 October 2026; use whatever the releases page shows.

The config.yaml keys that matter

Headscale reads /etc/headscale/config.yaml. These are the keys to set for an nginx front end; take the full example for your exact version from the GitHub tag, because the main branch can carry unreleased changes.

# /etc/headscale/config.yaml: edit these keys in place, do not append a second copy
server_url: https://hs.example.com
listen_addr: 127.0.0.1:8080
metrics_listen_addr: 127.0.0.1:9090
grpc_listen_addr: 127.0.0.1:50443
trusted_proxies:
  - 127.0.0.1/32
  - ::1/128
noise:
  private_key_path: /var/lib/headscale/noise_private.key
prefixes:
  v4: 100.64.0.0/10
  v6: fd7a:115c:a1e0::/48
derp:
  server:
    enabled: true
    stun_listen_addr: "0.0.0.0:3478"
    ipv4: <SERVER_PUBLIC_IPV4>
    ipv6: <SERVER_PUBLIC_IPV6>
  urls:
    - https://controlplane.tailscale.com/derpmap/default
tls_cert_path: ""
tls_key_path: ""
dns:
  base_domain: tailnet.example.net
  • server_url is the HTTPS name clients log in to. dns.base_domain, the MagicDNS suffix, must be a different domain from it.
  • listen_addr on 127.0.0.1:8080 with empty tls_cert_path and tls_key_path hands TLS to nginx; trusted_proxies makes Headscale read the forwarded client address.
  • noise.private_key_path holds the key that encrypts traffic between Headscale and clients; a missing key is generated on first start.
  • prefixes must stay subsets of 100.64.0.0/10 and fd7a:115c:a1e0::/48. The config warns that other ranges are unsupported and break clients in subtle ways.
  • The embedded DERP server is off by default. Enabled, it needs an HTTPS server_url and STUN on udp/3478, and the docs recommend setting the server's public IPv4 and IPv6 addresses for connection stability (leave out the ipv6 line if you did not add IPv6 at checkout); it joins Tailscale's default DERP map. urls: [] would drop that map and leave your box as the single relay, which the docs flag as a single point of failure.
  • With no policy file, Headscale allows all traffic between nodes. Set policy.path once there is more than one person on the tailnet.

Then run sudo headscale configtest, open STUN with sudo ufw allow 3478/udp (the block above enables the embedded DERP; if you set enabled: false, skip this and drop the ipv4/ipv6/stun_listen_addr lines), and sudo systemctl restart headscale. Check sudo ls -l /var/lib/headscale: the systemd unit the package ships sets UMask=0077, so noise_private.key, derp_server_private.key and db.sqlite should read -rw-------; if not, sudo chmod 600 them.

Headscale reverse proxy: the upgrade headers

Tailscale clients speak the Tailscale Control Protocol over a WebSocket, with two quirks a proxy must pass through: the upgrade arrives as a POST, and the Upgrade header value is tailscale-control-protocol. This nginx configuration, adapted from the docs' community page (the docs' upstream block is folded into a direct proxy_pass), handles both with a generic $http_upgrade map:

map $http_upgrade $connection_upgrade {
  default keep-alive;
  ''      close;
}
server {
  listen 80;
  listen [::]:80;
  server_name hs.example.com;
  location = /generate_204 { return 204; }
  location / { return 301 https://$server_name$request_uri; }
}
server {
  listen 443 ssl;
  listen [::]:443 ssl;
  server_name hs.example.com;
  ssl_certificate <PATH_TO_TLS_CERT>;
  ssl_certificate_key <PATH_TO_TLS_KEY>;
  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://127.0.0.1:8080;
  }
}

The docs' version also carries http2 on;, a directive that needs nginx 1.25.1 or newer; leave it out on older distribution builds. Use a full-chain certificate: the TLS page notes that some clients, the Android app among them, reject one without the chain. On Apache, ProxyPass needs upgrade=any. Cloudflare's proxy and tunnel are not supported, because they do not pass WebSocket POSTs. Know where the project stands before relying on this layout. The README says: "Please note that we do not support nor encourage the use of reverse proxies and container to run Headscale." The FAQ adds that the maintainers run no proxy themselves, and the reverse-proxy page is community documentation that the maintainers have not verified. Test without the proxy before chasing a proxy fault; the alternative is Headscale on 443 with its built-in Let's Encrypt client, which needs tcp/80 for the HTTP-01 challenge.

Users, pre-auth keys and OIDC

  1. Create a user: sudo headscale users create alice, then sudo headscale users list to read its numeric ID.
  2. Interactive enrolment: on the device, tailscale up --login-server https://hs.example.com; the page it opens prints an auth ID, which you approve with sudo headscale auth register --user alice --auth-id AUTH_ID.
  3. Unattended enrolment: sudo headscale preauthkeys create --user USER_ID prints a key that by default works once and expires after one hour; the device joins with tailscale up --login-server https://hs.example.com --authkey KEY.

A pre-auth key is a credential. It appears in your terminal when created, and on the device it sits in the shell history and in ps output while tailscale up runs. Keep it off shared screens, tickets and logs, and keep the single-use, one-hour default unless automation needs more.

For single sign-on, fill the oidc section: issuer, client_id, the secret, and pkce.enabled: true, with https://hs.example.com/oidc/callback as the redirect URI at the provider. The release build file (.goreleaser.yml) installs config.yaml as mode 0644, so rather than pasting the secret there, point client_secret_path at a file owned by headscale with mode 600. The docs list authentik and Keycloak as fully supported. With authentik, leave the provider's Encryption Key unset, since Headscale does not support JSON Web Encryption. With Keycloak, the allowed_groups filter needs a groups client scope with a Group Membership mapper. OIDC groups filter who may join; they cannot be used in policy rules.

Backups and upgrades

A standard install keeps everything in two places: /etc/headscale for the config and /var/lib/headscale for the SQLite database and the private keys. The upgrade guide's backup is a copy of both with cp -aR, which keeps the 600 modes. Stop the service first so SQLite is quiet, then move that copy off the server yourself; a copy on the same disk is not a backup. Treat it like the keys inside it.

  1. Read the release notes for the next version on GitHub.
  2. Stop Headscale: sudo systemctl stop headscale.
  3. Back up: TIMESTAMP=$(date +%Y%m%d%H%M%S), then sudo cp -aR /etc/headscale /etc/headscale.backup-$TIMESTAMP and the same for /var/lib/headscale.
  4. Install the next .deb with sudo apt install ./headscale.deb; the package's postinst script restarts the service on an upgrade, so make any config changes next.
  5. Compare your config with /usr/share/doc/headscale/examples/config-example.yaml, run sudo headscale configtest, then sudo systemctl restart headscale.

Go one minor version at a time, taking the latest patch of each (0.26.0, then 0.27.1, then 0.28.0 in the docs' example), because database migrations run on upgrade. Never apt purge headscale on a live server: the package's removal script (postrm) deletes /var/lib/headscale and the headscale user.

Where this setup stops

Container images exist (docker.io/headscale/headscale and ghcr.io/juanfont/headscale, tagged by version), but the FAQ says the project does not officially support Docker deployments, the container page is community-written, and the README line quoted above covers containers too. If you use them anyway, keep the docs' 127.0.0.1:8080:8080 and 127.0.0.1:9090:9090 mappings. A mapping with no address is published on 0.0.0.0 and [::]; on IPv4 that traffic skips ufw, and on IPv6 it does too when Docker's own IPv6 networking is on. A 127.0.0.1 mapping is reachable only from the host on Docker Engine 28 or newer.

Keep 9090 private: the debug endpoint shows connected nodes, the active policy and the DERP map. Reach it with ssh you@server -L 9090:localhost:9090. For a plain site-to-site link with no coordination server, a WireGuard server on its own is less to run.

Questions

Do the devices need Tailscale's client apps?

Yes. Headscale is only the control server; each device runs the regular Tailscale client pointed at your server_url with --login-server. Windows and Apple clients need the extra steps their pages on your Headscale instance describe.

Can the Headscale box also be an exit node?

Not in a supported way. The FAQ says running Headscale on a machine that is also in the tailnet can cause problems with subnet routers, traffic relay nodes and MagicDNS. Put the exit node on a second machine.

Is commercial use allowed?

The BSD 3-Clause licence permits it, and the FAQ says the project does not prevent commercial or professional use. It also says Headscale is not enterprise software, and performance is not a design priority.

Run the control server on your own machine

A whole server with root, IPv6 at checkout and an unmetered port, from a Moonshot cartridge up.

See the machines

Related on Server Room

Other use cases