Headscale 0.29.6 on AWS User Guide
Overview
This guide covers launching and verifying Headscale 0.29.6 on Ubuntu 24.04 from cloudimg on Amazon EC2. It is one version of the cloudimg Hardened WireGuard VPN & VPN, Proxy and Remote Access AMIs listing on AWS Marketplace; you pick it as the software version when you subscribe and launch. The other VPN, proxy and remote access servers in the listing are described on the listing's guide hub.
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, and it 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: cloudimg pins the checksum of the release package and of the headscale binary in it, and holds the package so nothing replaces it unnoticed. 0.29.6 is a security release (built with Go 1.27.3, with updated x/net, x/text and gRPC libraries).
What is on the image:
- Headscale 0.29.6 (
headscale.service): the control server, listening on loopback only (127.0.0.1:8080, metrics on127.0.0.1:9090) - nginx (
nginx.service): the only network-facing listener, serving the control server over HTTPS on port 443 with a certificate generated on your instance, and redirecting 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; no two instances share them
- No way in by default: no pre-auth key, no default user and no OIDC secret is baked into the image; 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(move the server to your own DNS name)
Default user: ubuntu · Architecture: x86_64 · Root volume: 8 GiB gp3
Security posture
- No default credential. First boot starts Headscale from an empty state directory (Headscale generates its own Noise private key and database), creates one REST API key valid for 365 days and writes it to
/root/headscale-credentials.txt, readable only by root. 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 it has none), read from the instance metadata service with IMDSv2. nginx answers only that address and the instance's public DNS name; a request for any other host name has its connection closed, and port 80 redirects to the configured address, never to the host name a client sent. - HTTPS with your instance's own certificate. The certificate and its private key are generated on first boot (RSA 2048, valid 10 years, TLS 1.2 and 1.3 only). Tailscale clients require HTTPS from the control server, so each client must trust this certificate (Step 5) or you 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 (theheadscalecommand talks to the server over a local Unix socket), and 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. A browser request from another site that tries to change state or open a WebSocket is refused by nginx; 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, and a start check (headscale-guard) refuses the unconfigured placeholder URL, a non-loopback listener, insecure gRPC or the embedded DERP relay. If first boot cannot finish, both stay stopped and the next boot starts over. - Secrets stay out of the logs. The API key is passed to
curlthrough a configuration file on a memory file system, never on a command line. cloudimg 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, stated plainly. No access-control policy is set, so Headscale applies its default: every device you register can reach every other device you register (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.com(it will not start without a relay map) and refreshes it every 24 hours. 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; that traffic stays end-to-end encrypted. - DNS. MagicDNS is on with the base domain
headscale.internal(devices reach each other as<device>.headscale.internal), and devices keep their own DNS resolvers: 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 (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): Headscale downloads the DERP relay map at start
- A public IP address (an Elastic IP is best, because the server URL is fixed at first boot) if devices outside the VPC will connect
Recommended instance type: m5.large. A control server is light; a few hundred devices need very little.
Connecting to your instance
| Version | Operating system | SSH user |
|---|---|---|
| Headscale 0.29.6 on Ubuntu 24.04 | Ubuntu 24.04 LTS | ubuntu |
Step 1: Launch the instance
Option A: AWS Marketplace console
- Open the cloudimg Hardened WireGuard VPN & VPN, Proxy and Remote Access AMIs listing on AWS Marketplace and 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 (
m5.largerecommended; the software charge is $0.04 per vCPU per hour) and your key pair. - Security group: allow SSH (22) from your own IP address only, and 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 (the image already requires IMDSv2), then choose Launch. If you want a stable address, allocate an Elastic IP and associate it before the first boot finishes, or 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, and 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: the AMI already makes IMDSv2 required.
Step 2: 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 it works. The details are in /root/headscale-credentials.txt, readable only by root. 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://www.cloudimg.co.uk/guides/headscale-0-29-6-on-ubuntu-24-04-aws/
Your values differ. To print the API key when you need it for the REST API:
sudo grep '^HEADSCALE_API_KEY=' /root/headscale-credentials.txt | cut -d= -f2-
Step 3: Confirm the server is running
Both services are active, Headscale listens on loopback only and nginx 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 -u
active
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"))
(+dirty is part of upstream's own version string for the 0.29.6 release build; the binary is the unmodified official release.)
Step 4: Run the self-test
The shipped self-test checks the services, the loopback listener and the server URL, and proves the REST API over HTTPS: it 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. It prints no secret:
sudo headscale-selftest
SELFTEST_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.168
Step 5: Trust the server certificate on your devices
Tailscale clients only talk to a control server over HTTPS 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 -sha256
subject=O = cloudimg, 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:AE
Copy 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\Root
For phones and for many devices, a certificate from a public CA for your own DNS name is easier (Step 9).
Step 6: Create a user and a pre-auth key
Every device belongs to a Headscale user. Create one, then a pre-auth key for it (here reusable for 24 hours; leave out --reusable for a single-use key). The key is printed once: treat it like a password.
sudo headscale users create alice
sudo headscale users list
sudo headscale preauthkeys create --user 1 --reusable --expiration 24h
User 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. sudo headscale preauthkeys list shows every key with its ID (the secret part is never shown again), and sudo headscale preauthkeys expire --id <id> revokes one.
Step 7: Connect a device
Install the official Tailscale client on the device (tailscale.com/download), make sure it trusts the certificate (Step 5), and 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 readable only by you and use --auth-key file:/path/to/key so it is not on the command line. Without --auth-key, the client prints a registration link; open it 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>.
On the server, the device appears with its 100.64.x.y address:
sudo headscale nodes list
ID | Hostname | Name | MachineKey | NodeKey | User | Tags | IP addresses | Ephemeral | Last seen | Expiration | Connected | Expired
On a new instance the list is empty. cloudimg 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.
Step 8: Use the REST API
The REST API is at https://<HEADSCALE_HOST>/api/v1/ and needs the API key as a bearer token. This reads the key from the credentials file and passes it to curl on standard input, so it 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: 401
From 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.
Step 9: Use your own DNS name (and a CA certificate)
The server URL is fixed at first boot to the instance's public IPv4. To use a DNS name, point an A record at the instance (an Elastic IP keeps it stable), then:
sudo headscale-set-server-url headscale.example.com
SET_SERVER_URL_OK server_url=https://headscale.example.com
It rewrites server_url, issues a new self-signed certificate for the name, restarts Headscale and reloads nginx. With no argument it returns to the instance's current address (use that after a stop and start changed 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 nginx
(Running headscale-set-server-url again issues a new self-signed certificate over it.) Devices then need no extra trust step.
Step 10: 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 it and applies it to every device at once. For example, to let 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 get
sudo headscale policy get reports that no policy is found until you set one. Headscale's policy reference is at headscale.net.
Step 11: Configuration
The configuration is /etc/headscale/config.yaml (readable by root and the headscale group). The settings cloudimg 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.yaml
6: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: false
To push name servers to your devices, list them under dns.nameservers.global (and set override_local_dns: true to make devices use them for every lookup), then restart: 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.
Step 12: 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 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/headscale
ConditionPathExists=/var/lib/cloudimg/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/headscale
Port 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/healthz
301 https://3.82.98.168/
ok
Step 13: Logs, backup and upgrades
journalctl -u headscale shows the server log (requests, registrations, errors; the lines below are the self-test's refused probes and its accepted request). A refused key is logged 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-140
2026-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=200
Backup: everything Headscale knows is in /var/lib/headscale (the SQLite database and the Noise private key). Stop the service, copy the directory, start it again; keep the copy as private as the server itself. EBS snapshots of the instance work too.
Upgrades: Ubuntu security updates install automatically. Headscale is held at 0.29.6; to upgrade, 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, then sudo apt-mark unhold headscale, sudo apt install ./headscale_<version>_linux_amd64.deb, sudo apt-mark hold headscale and sudo systemctl restart headscale. cloudimg also ships updated versions as new versions of this listing.
Step 14: Security recommendations
- Keep SSH open only to your management addresses.
- Use an Elastic IP or a DNS name, so the server URL your devices know never changes under them.
- Prefer single-use or short-lived pre-auth keys, and expire keys 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 licence is in /usr/share/doc/headscale/LICENSE. cloudimg 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.
cloudimg 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 support@cloudimg.co.uk or visit cloudimg support.