Headscale 0.29.6
Overview
This guide shows you how to launch and verify Headscale 0.29.6 on Ubuntu 24.04 from SecureAnyCloud on Amazon EC2. It is one version of the SecureAnyCloud Hardened WireGuard VPN & VPN, Proxy and Remote Access AMIs listing on AWS Marketplace. You choose it as the software version when you subscribe and launch. The listing's guide hub describes the other VPN, proxy and remote access servers in the listing.
Headscale is an open-source, self-hosted implementation of the Tailscale control server. The official Tailscale clients (Linux, Windows, macOS, iOS, Android) log in to it instead of to Tailscale's hosted service. Headscale hands out addresses in 100.64.0.0/10, distributes WireGuard keys and peer lists, and applies your access policy.
The traffic between your devices is end-to-end encrypted WireGuard and never passes through the control server. Headscale has no web interface. You run it with its command-line tool over SSH and with its REST API.
Headscale 0.29.6 is installed from the official release. SecureAnyCloud pins the checksum of the release package and of the headscale binary in it, and holds the package so nothing replaces it unnoticed. Version 0.29.6 is a security release, built with Go 1.27.3 and with updated x/net, x/text and gRPC libraries.
What you get:
- Headscale 0.29.6 (
headscale.service): the control server, listening on loopback only (127.0.0.1:8080, with metrics on127.0.0.1:9090) - nginx (
nginx.service): the only network-facing listener, which serves the control server over HTTPS on port 443 with a certificate generated on your instance and redirects port 80 to HTTPS - A server of its own: the server URL, the REST API key, Headscale's Noise private key and the TLS key are generated on your instance's first boot, and no two instances share them
- No way in by default: the image contains no pre-auth key, no default user and no OIDC secret, so a device joins only with a pre-auth key you create or after you approve its registration
- Helpers:
headscale-selftest(an end-to-end check) andheadscale-set-server-url(moves the server to your own DNS name)
The default user is ubuntu. The architecture is x86_64, and the root volume is 8 GiB gp3.
Security posture
- No default credential. First boot starts Headscale from an empty state directory, and Headscale generates its own Noise private key and database. First boot also creates one REST API key valid for 365 days and writes it to
/root/headscale-credentials.txt, which only root can read. The image contains no API key, pre-auth key, node, user or database. - Server URL from the instance, never from a request. First boot sets
server_urltohttps://<this instance's public IPv4>(the private IPv4 if the instance has none), read from the instance metadata service with IMDSv2. nginx answers only that address and the instance's public DNS name. It closes the connection for a request to any other host name. Port 80 redirects to the configured address, never to the host name a client sent. - HTTPS with your instance's own certificate. First boot generates the certificate and its private key (RSA 2048, valid 10 years, TLS 1.2 and 1.3 only). Tailscale clients require HTTPS from the control server. Each client must therefore trust this certificate (Step 5), or you can install a certificate from a public CA for your own DNS name (Step 9).
- Loopback only, exact ports. Headscale listens on
127.0.0.1:8080and127.0.0.1:9090only. No gRPC TCP port is opened, because theheadscalecommand talks to the server over a local Unix socket. Headscale's embedded DERP relay is off, so there is no STUN port. The instance listens on TCP 22, 80 and 443 and nothing else. - Client addresses from the connection. nginx overwrites
X-Forwarded-ForandX-Real-IPwith the address of the connection and removesTrue-Client-IP,ForwardedandX-Forwarded-Host. Headscale trusts those headers only from nginx on loopback, so a client cannot fake its logged address. - Cross-site requests refused. nginx refuses a browser request from another site that tries to change state or open a WebSocket. Tailscale clients and command-line tools send no
Originheader and are unaffected. - No SSRF to instance credentials.
headscale.serviceandnginx.servicecarry anIPAddressDenyrule for the metadata addresses (169.254.169.254, fd00:ec2::254, 168.63.129.16), so neither service can reach the instance metadata service. The AMI is registered IMDSv2 only. - Least privilege. Headscale runs as the unprivileged
headscaleuser with no capabilities, a read-only system (ProtectSystem=strict), no access to home directories, and write access to/var/lib/headscaleonly. - Fails closed.
headscale.serviceandnginx.servicestart only after first boot has written its ready marker. A start check (headscale-guard) refuses an unconfigured server URL, a non-loopback listener, insecure gRPC and the embedded DERP relay. If first boot cannot finish, both services stay stopped and the next boot starts over. - Secrets stay out of the logs. The API key is passed to curl through a configuration file on a memory file system, never on a command line. SecureAnyCloud checked that the API key, a pre-auth key and the Noise key appear in no log file and not in the journal.
- Access policy: Headscale's default. No access-control policy is set, so Headscale applies its default: every device you register can reach every other device you register. This is the same default as Tailscale's own service. Only you can register devices. Step 10 shows how to set a policy.
- What the server contacts. At start, Headscale downloads the public Tailscale DERP relay map from
controlplane.tailscale.comand refreshes it every 24 hours. Headscale will not start without a relay map. The update check is off, and clients are told not to send their logs to Tailscale. When two devices cannot connect directly, they relay through Tailscale's public DERP servers, and that traffic stays end-to-end encrypted. - DNS. MagicDNS is on with the base domain
headscale.internal, so devices reach each other as<device>.headscale.internal. Devices keep their own DNS resolvers, and no third-party name server is pushed to them (Step 11). - Per-instance identity. SSH host keys and the machine ID are generated fresh on every instance.
- Security notes.
govulncheckfinds no vulnerability in Headscale 0.29.6, in a source scan of the release tag and a symbol-level scan of a build of it.
Prerequisites
- An AWS account and permission to launch EC2 instances
- An EC2 key pair in the Region you launch in
- A subnet with outbound HTTPS access (a public subnet, or a private subnet with a NAT gateway), because Headscale downloads the DERP relay map at start
- A public IP address if devices outside the VPC will connect. An Elastic IP is best, because the server URL is fixed at first boot.
Recommended instance type: m5.large. A control server is light, and a few hundred devices need very little.
Connecting to your instance
Headscale 0.29.6 runs on Ubuntu 24.04 LTS. Connect over SSH as the user ubuntu.
Launch the instance
Option A: AWS Marketplace console
- Open the SecureAnyCloud Hardened WireGuard VPN & VPN, Proxy and Remote Access AMIs listing on AWS Marketplace. Choose View purchase options, then Subscribe. One subscription covers every version in the listing.
- Once the subscription is active, choose Launch your software.
- Choose Launch from EC2 Console. In Version, choose Headscale 0.29.6 on Ubuntu 24.04, then choose your Region.
- Pick an instance type and your key pair. We recommend
m5.large. The software charge is $0.04 per vCPU per hour. - Configure the security group to allow SSH (22) from your own IP address only. Allow HTTPS (443) and HTTP (80) from the networks your devices connect from (
0.0.0.0/0for laptops and phones that roam). - Choose a subnet with internet access and a public IP. Leave the metadata defaults, because the image already requires IMDSv2. Then choose Launch.
- For a stable address, allocate an Elastic IP and associate it before the first boot finishes. Alternatively, run
sudo headscale-set-server-urlafterwards (Step 9).
Option B: AWS CLI
Run this on your own workstation. It creates a security group that allows SSH from your management address and HTTPS/HTTP from anywhere, then launches the instance.
Replace the AMI ID with the one shown for your Region on the Marketplace launch page. Replace the VPC, subnet, key pair and address range with your own:
SG=$(aws ec2 create-security-group --group-name headscale \
--description "Headscale control server" --vpc-id vpc-0123456789abcdef0 \
--query GroupId --output text)
aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 22 --cidr <your-mgmt-cidr>
aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 443 --cidr 0.0.0.0/0
aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 80 --cidr 0.0.0.0/0
aws ec2 run-instances \
--image-id ami-0123456789abcdef0 \
--instance-type m5.large \
--key-name my-key-pair \
--security-group-ids "$SG" \
--subnet-id subnet-0123456789abcdef0 \
--associate-public-ip-address \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=headscale}]'You do not need --metadata-options, because the AMI already requires IMDSv2.
Connect and read your server details
Connect as ubuntu with the key pair you chose:
ssh -i ~/.ssh/my-key-pair.pem ubuntu@<public-ip>First boot finishes within a minute of launch. It sets the server URL, creates the certificate, starts Headscale and nginx, creates the REST API key and proves that the key works.
The details are in /root/headscale-credentials.txt, which only root can read. This command shows everything except the API key:
sudo grep -v '^HEADSCALE_API_KEY=' /root/headscale-credentials.txt# Headscale 0.29.6 (Tailscale control server) - generated on first boot by headscale-firstboot.service.
# Every value below is unique to this instance; nothing was baked into the image. Keep this file private.
# Control server URL: point Tailscale clients at it with tailscale up --login-server <URL>
# Change it (your own DNS name):
sudo headscale-set-server-url <dns-name> HEADSCALE_SERVER_URL=https://3.82.98.168 HEADSCALE_HOST=3.82.98.168
# REST API key (Authorization: Bearer <key>), valid until 2027-10-27. Manage keys:
sudo headscale apikeys list HEADSCALE_API_KEY_EXPIRES=2027-10-27
# The certificate is self-signed and unique to this instance (/etc/headscale/tls/headscale.crt); clients must
# trust it (see the guide) or you install a CA-signed certificate for your own DNS name. TLS_CERT_SHA256=84:FB:65:69:64:6A:35:35:97:64:9E:4A:84:A0:40:AD:29:49:13:19:00:EF:0A:24:F5:3A:3E:C6:3C:C2:75:AE
# Fingerprint (sha256 prefix) of this instance's Noise private key file (the key itself never leaves the server). HEADSCALE_NOISE_KEY_FINGERPRINT=44d2569cb61f7ffb # Self-test:
sudo headscale-selftest
# Guide: https://secureanycloud.com/stacks/headscale-0-29-6/Your values differ from these. To print the API key when you need it for the REST API, run:
sudo grep '^HEADSCALE_API_KEY=' /root/headscale-credentials.txt | cut -d= -f2-Confirm the server is running
Check that both services are active, that Headscale listens on loopback only, and that nginx listens on ports 80 and 443:
systemctl is-active headscale.service nginx.service headscale version | head -1 sudo ss -Htlnp | awk '{print $4, $6}' | grep -E ':(22|80|443|8080|9090) ' | sed -E 's/,pid=[0-9]+,fd=[0-9]+//g' | sort -uactive active headscale version v0.29.6+dirty 0.0.0.0:22 users:(("sshd"),("systemd")) 0.0.0.0:443 users:(("nginx"),("nginx"),("nginx")) 0.0.0.0:80 users:(("nginx"),("nginx"),("nginx")) 127.0.0.1:8080 users:(("headscale")) 127.0.0.1:9090 users:(("headscale")) [::]:22 users:(("sshd"),("systemd")) [::]:443 users:(("nginx"),("nginx"),("nginx")) [::]:80 users:(("nginx"),("nginx"),("nginx"))The +dirty suffix is part of upstream's own version string for the 0.29.6 release build. The binary is the unmodified official release.
Run the self-test
The shipped self-test checks the services, the loopback listener and the server URL. It also proves the REST API over HTTPS: the API answers 401 without a key and for a key with the right ID but the wrong secret, and 200 with the key in the credentials file. The self-test prints no secret:
sudo headscale-selftestSELFTEST_OK headscale+nginx active; loopback 127.0.0.1:8080; health 200; REST API 401 anonymous/wrong, 200 per-instance key; server_url https://3.82.98.168Trust the server certificate on your devices
Tailscale clients talk to a control server only over HTTPS that they trust. The certificate is unique to your instance. Check its fingerprint against TLS_CERT_SHA256 from Step 2:
sudo openssl x509 -in /etc/headscale/tls/headscale.crt -noout -subject -enddate -ext subjectAltName
sudo openssl x509 -in /etc/headscale/tls/headscale.crt -noout -fingerprint -sha256subject=O = sac, CN = 3.82.98.168 notAfter=Oct 24 11:12:25 2036 GMT X509v3 Subject Alternative Name: IP Address:127.0.0.1, IP Address:3.82.98.168, DNS:ec2-3-82-98-168.compute-1.amazonaws.com sha256 Fingerprint=84:FB:65:69:64:6A:35:35:97:64:9E:4A:84:A0:40:AD:29:49:13:19:00:EF:0A:24:F5:3A:3E:C6:3C:C2:75:AECopy the certificate to your workstation and add it to each device's trusted roots:
ssh -i ~/.ssh/my-key-pair.pem ubuntu@<public-ip> 'sudo cat /etc/headscale/tls/headscale.crt' > headscale.crt
# Linux (Debian/Ubuntu):
sudo cp headscale.crt /usr/local/share/ca-certificates/headscale.crt && sudo update-ca-certificates
# macOS:
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain headscale.crt
# Windows (elevated PowerShell): Import-Certificate -FilePath headscale.crt -CertStoreLocation Cert:\LocalMachine\RootFor phones and for large numbers of devices, a certificate from a public CA for your own DNS name is easier (Step 9).
Create a user and a pre-auth key
Every device belongs to a Headscale user. Create a user, then create a pre-auth key for it. The example key is reusable for 24 hours. Leave out --reusable for a single-use key.
Headscale prints the key once, so treat it like a password.
sudo headscale users create alice
sudo headscale users list
sudo headscale preauthkeys create --user 1 --reusable --expiration 24hUser created ID | Name | Username | Email | Created 1 | | alice | | 2026-10-27 11:02:02 hskey-auth-<prefix>-<secret>Use the numeric ID from users list with --user. The command sudo headscale preauthkeys list shows every key with its ID, but never the secret part again. To revoke a key, run sudo headscale preauthkeys expire --id <id>.
Connect a device
Install the official Tailscale client on the device (tailscale.com/download) and make sure it trusts the certificate (Step 5). Then log in to your server with the pre-auth key:
sudo tailscale up --login-server https://<HEADSCALE_HOST> --auth-key hskey-auth-<prefix>-<secret>On a shared machine, put the key in a file that only you can read and use --auth-key file:/path/to/key, so the key is not on the command line.
Without --auth-key, the client prints a registration link. Open the link to see the command, then approve the device on the server with sudo headscale nodes register --user alice --key <registration-id>.
The Tailscale apps for Windows, macOS, iOS and Android have a setting for a custom coordination server (a "custom login server" / "alternate coordination server URL"). Enter https://<HEADSCALE_HOST> there.
On the server, the device appears with its 100.64.x.y address:
sudo headscale nodes listID | Hostname | Name | MachineKey | NodeKey | User | Tags | IP addresses | Ephemeral | Last seen | Expiration | Connected | ExpiredOn a new instance the list is empty.
SecureAnyCloud tested registration on the image with the official Tailscale 1.104.2 client. A wrong pre-auth key is refused, and a valid one registers the device with a 100.64.0.0/10 address and brings it online.
Use the REST API
The REST API is at https://<HEADSCALE_HOST>/api/v1/ and needs the API key as a bearer token.
This example reads the key from the credentials file and passes it to curl on standard input, so the key never appears on a command line. Without the key, the API answers 401:
H=$(sudo sed -n 's/^HEADSCALE_HOST=//p' /root/headscale-credentials.txt)
sudo cat /etc/headscale/tls/headscale.crt > /tmp/headscale.crt
sudo sed -n 's/^HEADSCALE_API_KEY=//p' /root/headscale-credentials.txt \
| sed 's/^/header = "Authorization: Bearer /; s/$/"/' \
| curl -sS -K - --cacert /tmp/headscale.crt --resolve "$H:443:127.0.0.1" "https://$H/api/v1/node" echo curl -sS -o /dev/null -w 'without the key: %{http_code}\n' --cacert /tmp/headscale.crt --resolve "$H:443:127.0.0.1" "https://$H/api/v1/node" rm -f /tmp/headscale.crt{"nodes":[]} without the key: 401From your workstation, call https://<HEADSCALE_HOST>/api/v1/... with the header Authorization: Bearer <api-key>.
Manage keys with sudo headscale apikeys list, sudo headscale apikeys create --expiration 90d and sudo headscale apikeys expire --prefix <prefix>. The key in the credentials file expires on the date shown in Step 2.
Use your own DNS name (and a CA certificate)
First boot fixes the server URL to the instance's public IPv4. To use a DNS name instead, point an A record at the instance (an Elastic IP keeps the address stable), then run:
sudo headscale-set-server-url headscale.example.comSET_SERVER_URL_OK server_url=https://headscale.example.comThe command rewrites server_url, issues a new self-signed certificate for the name, restarts Headscale and reloads nginx.
With no argument, the command returns the server to the instance's current address. Use that after a stop and start changes the public IP; the login banner warns you when it has. Devices registered under the old URL log in again with tailscale up --login-server https://<new-name> --force-reauth.
To replace the self-signed certificate with one from a public CA for that name, put the certificate chain and key in place and reload nginx:
sudo install -m 0644 fullchain.pem /etc/headscale/tls/headscale.crt
sudo install -m 0600 privkey.pem /etc/headscale/tls/headscale.key
sudo systemctl reload nginxDevices then need no extra trust step. Running headscale-set-server-url again issues a new self-signed certificate over the CA certificate.
Access policy
With no policy set, every registered device can reach every other registered device. To restrict that, write a policy in Tailscale's HuJSON format and load it. Headscale validates the policy and applies it to every device at once.
This example lets each user reach only their own devices:
cat > policy.hujson <<'EOF' { "acls": [ { "action": "accept", "src": ["autogroup:member"], "dst": ["autogroup:self:*"] } ] } EOF
sudo headscale policy set -f policy.hujson
sudo headscale policy getUntil you set a policy, sudo headscale policy get reports that no policy is found. Headscale's policy reference is at headscale.net.
Configuration
The configuration file is /etc/headscale/config.yaml, which root and the headscale group can read. This command shows the settings that SecureAnyCloud chose:
sudo grep -nE '^(server_url|listen_addr|metrics_listen_addr|grpc_allow_insecure|trusted_proxies)|^ (magic_dns|base_domain|override_local_dns)|^ enabled' /etc/headscale/config.yaml6:server_url: https://3.82.98.168 9:listen_addr: 127.0.0.1:8080 10:metrics_listen_addr: 127.0.0.1:9090 14:grpc_allow_insecure: false 18:trusted_proxies: 35: enabled: false 71: magic_dns: true 72: base_domain: headscale.internal 74: override_local_dns: falseTo push name servers to your devices, list them under dns.nameservers.global. Set override_local_dns: true to make devices use them for every lookup. Then restart Headscale with sudo systemctl restart headscale.
Keep listen_addr on loopback. The start check refuses anything else, because nginx is the only listener meant to face the network. Change server_url with headscale-set-server-url (Step 9), not by hand.
For relayed traffic, devices use Tailscale's public DERP servers. To run your own relay, run Tailscale's derper on a separate instance and list it in a DERP map file under derp.paths. The embedded relay stays off on this image.
How the fail-closed start works
headscale.service runs only when first boot's ready marker exists. Every start runs the guard as root first, and both services are denied access to the metadata service:
systemctl cat headscale.service | grep -E '^(ConditionPathExists|ExecStartPre|ExecStart|User|ProtectSystem|CapabilityBoundingSet)='
systemctl show -p IPAddressDeny headscale.service nginx.service
sudo stat -c '%a %U:%G %n' /root/headscale-credentials.txt /etc/headscale/config.yaml /etc/headscale/tls/headscale.key /var/lib/headscaleConditionPathExists=/var/lib/sac/data/headscale-bootstrap-ready User=headscale ExecStartPre=+/usr/local/sbin/headscale-guard ExecStart=/usr/bin/headscale serve ProtectSystem=strict CapabilityBoundingSet= IPAddressDeny=168.63.129.16/32 fd00:ec2::254/128 169.254.169.254/32 IPAddressDeny=168.63.129.16/32 fd00:ec2::254/128 169.254.169.254/32 600 root:root /root/headscale-credentials.txt 640 root:headscale /etc/headscale/config.yaml 600 root:root /etc/headscale/tls/headscale.key 750 headscale:headscale /var/lib/headscalePort 80 only redirects to the configured server URL and answers a health probe at /healthz:
curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' http://127.0.0.1/
curl -sS http://127.0.0.1/healthz301 https://3.82.98.168/ okLogs, backup and upgrades
journalctl -u headscale shows the server log, which covers requests, registrations and errors. The lines below are the self-test's refused probes and its accepted request. Headscale logs a refused key by reason only, never by value.
/var/log/nginx/ holds the HTTPS access log. Registration links are not logged:
sudo journalctl -u headscale --no-pager -n 5 -o cat | cut -c1-1402026-10-27T11:12:57Z ERR ../../../home/runner/work/headscale/headscale/hscontrol/app.go:486 > missing "Bearer " prefix in "Authorization" he 2026-10-27T11:12:57Z INF http request bytes=12 elapsed=0.168616 method=GET path=/api/v1/apikey proto=HTTP/1.1 remote=127.0.0.1 status=401 2026-10-27T11:12:57Z INF ../../../home/runner/work/headscale/headscale/hscontrol/app.go:497 > failed to validate token error="invalid API ke 2026-10-27T11:12:57Z INF http request bytes=12 elapsed=70.147782 method=GET path=/api/v1/apikey proto=HTTP/1.1 remote=127.0.0.1 status=401 2026-10-27T11:12:57Z INF http request bytes=12 elapsed=72.186011 method=GET path=/api/v1/node proto=HTTP/1.1 remote=127.0.0.1 status=200Backup: Everything Headscale knows is in /var/lib/headscale, which holds the SQLite database and the Noise private key. Stop the service, copy the directory, and start the service again. Keep the copy as private as the server itself. EBS snapshots of the instance also work.
Upgrades: Ubuntu security updates install automatically. Headscale is held at 0.29.6. To upgrade, follow these steps:
- Read the upgrade notes.
- Back up /var/lib/headscale.
- Download the new headscale_<version>_linux_amd64.deb from the releases page.
- Check its SHA-256 against the release's checksums.txt.
- Run sudo apt-mark unhold headscale.
- Run sudo apt install ./headscale_<version>_linux_amd64.deb.
- Run sudo apt-mark hold headscale.
- Run sudo systemctl restart headscale.
SecureAnyCloud also ships updated versions as new versions of this listing.
Security recommendations
- Keep SSH open only to your management addresses.
- Use an Elastic IP or a DNS name, so the server URL that your devices know never changes under them.
- Prefer single-use or short-lived pre-auth keys, and expire keys that you no longer need (
sudo headscale preauthkeys expire --id <id>). - Set an access policy (Step 10) once you have more than a handful of devices or more than one user.
- Rotate the REST API key before it expires, and expire any key that may have been exposed (
sudo headscale apikeys expire --prefix <prefix>). - Back up
/var/lib/headscaleregularly and keep the backup private. Whoever has the Noise key and database can impersonate your control server.
Support and licensing
Headscale is open-source software by Juan Font and the Headscale contributors, distributed under the BSD 3-Clause License. The license is in /usr/share/doc/headscale/LICENSE.
SecureAnyCloud provides the packaging, the per-instance server URL, key and certificate, the HTTPS front end and the other secure defaults, the fail-closed start, the metadata block, the self-test and 24/7 support.
SecureAnyCloud is not affiliated with or endorsed by the Headscale project or by Tailscale Inc. Headscale is an independent project. Tailscale is a trademark of Tailscale Inc. and is used here only to identify the compatible client software.
For help with deployment or configuration, email sales@secureanycloud.com or visit SecureAnyCloud support.
Deploy on AWS
Launch Headscale 0.29.6 on Ubuntu 24.04 on AWS with 24/7 support from SecureAnyCloud.
View on Marketplace
Need Help?
Our support team is available 24/7.
sales@secureanycloud.com