Headscale Control Server Setup Guide
A guide to running your own headscale control server (a self-hosted Tailscale coordination server) on a public Linux VM, secured with HTTPS via Let’s Encrypt, and optionally turned into a VPN exit node.
|
This guide is written from a real, working setup, and it deliberately calls out
the gotchas encountered along the way. Issues are flagged inline with
|
Assumptions
-
The server is a generic systemd-based Linux distro (Debian, Arch, etc.). The author runs NixOS, so the declarative equivalents appear in
NOTEcallouts, but the main path is plain Linux +config.yaml. -
You have a cloud VM (the examples assume AWS EC2, but any provider works) with a public IP. Cloud-provider VM setup itself is out of scope.
Requirements
-
A public Linux VM with a static public IP. On EC2, allocate and associate an Elastic IP so the address survives stop/start — if the IP changes, both DNS and the issued certificate break.
-
A custom domain you control.
Do not use the provider’s auto-assigned hostname (e.g.
*.compute.amazonaws.com). Let’s Encrypt will not issue certificates for those, so TLS — and therefore the whole setup — will fail. A custom (sub)domain is mandatory. -
Required software (install via your distro’s package manager; the exact commands are intentionally omitted — only the packages matter):
-
Server:
headscale(the control server) andtailscale— the server itself must run a tailscale client if you want it to be an exit node. -
Clients:
tailscale. -
Optional:
ethtool— only for exit-node throughput tuning (see below).
-
Ports
Open these in both the OS firewall and the cloud security group:
| Port | When it’s needed |
|---|---|
|
Always. HTTPS for clients, and the TLS-ALPN-01 ACME challenge. |
|
Only if you use the HTTP-01 ACME challenge instead of TLS-ALPN-01. |
|
Only if you enable the embedded DERP relay. |
This guide uses TLS-ALPN-01, so only tcp/443 is required.
Step 1 — Point your domain at the server
At your DNS provider (Cloudflare, etc.), add an A record for your chosen subdomain pointing to the server’s public/Elastic IP:
headscale.example.com. A <YOUR_SERVER_PUBLIC_IP>
|
If you use Cloudflare, set Proxy status to DNS only (grey cloud). The orange-cloud proxy terminates TLS itself and will break headscale’s own Let’s Encrypt + the Tailscale clients' expectations. |
Verify it resolves before continuing — the certificate cannot be issued until the name points at the box:
dig +short headscale.example.com
Step 2 — Configure headscale (server)
headscale reads /etc/headscale/config.yaml. The important parts:
# The public URL clients register against.
server_url: https://headscale.example.com
# Listen on all interfaces, on 443.
listen_addr: 0.0.0.0:443
# --- Built-in Let's Encrypt (no nginx / certbot needed) ---
tls_letsencrypt_hostname: headscale.example.com
# TLS-ALPN-01 runs the ACME challenge over 443, so port 80 is NOT needed.
tls_letsencrypt_challenge_type: TLS-ALPN-01
# --- DNS ---
dns:
magic_dns: false
nameservers:
global:
- 1.1.1.1
- 1.0.0.1
|
Let’s Encrypt is automatic
headscale obtains and renews the certificate itself — you do not need nginx
or a separate certbot. Just set On NixOS this is fully declarative — the cert is fetched automatically on first start:
|
|
Issue I hit: DNS config requires global nameservers
Setting only dns.nameservers.global must be set when overriding local DNS The fix is to provide global nameservers (the
|
|
What MagicDNS is (and why it’s off here)
MagicDNS lets devices on your tailnet reach each other by name instead of by
raw
It’s off here purely to keep the setup minimal: it avoids needing a
MagicDNS is not a security feature — on or off, it changes only how you
address peers (names vs |
|
Issue I hit: headscale can’t bind port 443
headscale runs as a non-root On generic systemd, add a drop-in ( [Service] AmbientCapabilities=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE On NixOS:
|
Step 3 — Start headscale and verify
sudo systemctl enable --now headscale journalctl -u headscale -f
In the logs, watch for the ACME certificate being obtained and headscale listening. Then confirm from the outside (your laptop), which tests DNS, the firewall/security group, and the cert end to end:
curl -I https://headscale.example.com
A valid (non-self-signed) response means TLS-ALPN-01 succeeded and you’re publicly reachable.
|
The certificate can only be issued once DNS points at the box and 443 is reachable. If the cert never appears, that’s almost always the cause — not a headscale bug. |
Step 4 — Create a user and a pre-auth key
On the server. First a user, then a pre-auth key for it:
headscale users create <user> headscale users list # note the user's ID headscale preauthkeys create --user <user> --reusable --expiration 8760h
-
8760his ~1 year (Go-style duration;his the largest reliable unit). -
--reusablelets several devices enroll with the same key.
|
Pre-auth keys are immutable — you cannot edit a key’s expiration after the
fact. To change it, create a new key and |
Step 5 — Join a client
Install tailscale on the client, then point it at your server instead of
Tailscale’s:
sudo tailscale up --login-server https://headscale.example.com --authkey <KEY>
|
Issue I hit:
--login-server must include https://Running it with a bare hostname ( |
Verify the device joined — on the server headscale nodes list should show it
with a 100.x.y.z address, and on the client tailscale status lists peers.
|
MagicDNS is off in this guide, so there are no automatic hostnames — reach peers
by their |
Step 6 — Exit node (optional)
Turn the server into a VPN exit node so clients can route their internet traffic through it.
Server side
Enable IP forwarding
An exit node forwards traffic, which the kernel won’t do without IP forwarding.
|
Issue I hit
IP forwarding is disabled, subnet routing/exit nodes will not work. Enable it. On generic Linux: echo 'net.ipv4.ip_forward = 1' | sudo tee -a /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 On NixOS, don’t hand-edit
Verify either way: sysctl net.ipv4.ip_forward net.ipv6.conf.all.forwarding # both should be 1 |
|
Optional: UDP GRO warning
You may also see a warning that UDP GRO forwarding is suboptimally configured
on the network interface. This only affects exit-node throughput, not
correctness. If you care, apply Tailscale’s recommended |
Advertise and approve the exit node
Run a tailscale client on the server and advertise it as an exit node:
sudo tailscale up --login-server https://headscale.example.com --authkey <KEY> --advertise-exit-node
|
If a later plain |
Advertising only offers the routes (0.0.0.0/0, ::/0); headscale won’t hand
them to clients until you approve them. On the server:
headscale nodes list # find the exit node's ID headscale nodes approve-routes --identifier <ID> --routes "0.0.0.0/0,::/0" headscale nodes list-routes # confirm: Approved + Serving show 0.0.0.0/0 and ::/0
|
Issue I hit: "no exit nodes found"
Two things confused me here:
|
Client side
After approval, the client can see and select the exit node:
tailscale exit-node list # the server should now appear sudo tailscale set --exit-node=<EXIT_NODE_100.x_IP> --exit-node-allow-lan-access
-
Get the
100.xIP fromheadscale nodes list/tailscale status. -
--exit-node-allow-lan-accesskeeps your local LAN (printer, NAS) reachable while everything else routes through the server.
Confirm your egress IP changed (run on the client):
curl https://ipv4.icanhazip.com/ # should now be the SERVER's public IP
Turn it back off (route directly again):
sudo tailscale set --exit-node=
|
Issue I hit: exit node breaks org-restricted services
An exit node is full tunnel — all your traffic exits via the server’s IP, and there’s no native per-app/per-destination split. After enabling it, services that gate access by IP (e.g. a corporate Google Workspace / Meet behind conditional access or an IP allowlist) stopped working, because my apparent IP was now the cloud VM instead of the expected one. There’s no clean way to exclude just those services. The practical approach is to
toggle the exit node off ( |
Troubleshooting (collected gotchas)
| Symptom | Cause / fix |
|---|---|
|
|
Client: |
Routes not approved on the server. Run
|
|
Expected — a node never lists itself. Check from another client. |
headscale won’t start, permission denied on |
Non-root service can’t bind a privileged port. Grant
|
Config error: |
Provide |
Certificate never issues |
DNS not pointing at the box yet, or 443 not reachable (firewall / security group). TLS-ALPN-01 must reach the server on 443. |
Exit node warns IP forwarding is disabled |
Enable |
Exit node works but org services (Meet, etc.) break |
Full-tunnel changed your egress IP. Toggle the exit node off when you need IP-restricted services. |
Exit-node advertisement disappeared |
A plain |
Security considerations
| Item | Notes & mitigation | Origin IP is public (DNS "DNS only") |
|---|---|---|
Pointing the record straight at the server — required for TLS-ALPN-01 and direct client connections — exposes the server’s IP. Unavoidable for a control server clients connect to; rely on the firewall + patching. |
|
Granting just this one capability to bind |
|
MagicDNS being on or off is not a security issue — it only changes how you
address peers (names vs |
|
NixOS: secrets in a flake input
If you read values from a |