H0
Networking AWS

Headscale 0.29.6 on AWS User Guide

| Product: Headscale 0.29.6 on Ubuntu 24.04 on AWS

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 on 127.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) and headscale-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_url to https://<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:8080 and 127.0.0.1:9090 only. No gRPC TCP port is opened (the headscale command 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-For and X-Real-IP with the address of the connection and removes True-Client-IP, Forwarded and X-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 Origin header and are unaffected.
  • No SSRF to instance credentials. headscale.service and nginx.service carry an IPAddressDeny rule 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 headscale user with no capabilities, a read-only system (ProtectSystem=strict), no access to home directories and write access to /var/lib/headscale only.
  • Fails closed. headscale.service and nginx.service start 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 curl through 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. govulncheck finds no vulnerability in Headscale 0.29.6 (source scan of the release tag and a symbol-level scan of a build of it).

Prerequisites

  1. An AWS account and permission to launch EC2 instances
  2. An EC2 key pair in the Region you launch in
  3. 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
  4. 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

  1. 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.
  2. Once the subscription is active, choose Launch your software.
  3. Choose Launch from EC2 Console. In Version, choose Headscale 0.29.6 on Ubuntu 24.04, then choose your Region.
  4. Pick an instance type (m5.large recommended; the software charge is $0.04 per vCPU per hour) and your key pair.
  5. 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/0 for laptops and phones that roam).
  6. 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-url afterwards (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/headscale regularly 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.