He
Networking Azure

Headscale on Ubuntu 24.04 on Azure User Guide

| Product: Headscale on Ubuntu 24.04 LTS on Azure

Overview

This guide covers the deployment and configuration of Headscale on Ubuntu 24.04 on Azure using cloudimg Azure Marketplace images. Headscale is an open source, self hosted implementation of the Tailscale control server (the coordination server, sometimes called the control plane). It lets you run your own private mesh network: machines register with the control server using pre auth keys or OIDC, receive an address from the 100.64.0.0/10 range, and then connect directly to each other over an encrypted WireGuard based overlay.

The image installs the latest stable Headscale release, resolved at build time from the official GitHub releases and baked reproducibly into the image (the exact version and checksum are recorded in /opt/headscale/VERSION). Headscale runs as a single Go binary under systemd as the unprivileged headscale system user. The Headscale binary binds to loopback only (127.0.0.1:8080); nginx is the sole network facing surface and terminates TLS on port 443, reverse proxying to Headscale with the settings the Tailscale control protocol needs (HTTP/1.1, unbuffered long poll streaming and long timeouts).

HTTPS is required. Tailscale clients only connect to a control server over TLS. On the very first boot this image regenerates a self signed TLS certificate for this specific VM (the certificate Subject Alternative Names include the VM public IP and hostname), sets the control server URL to https://<your public ip>, and generates a per instance administrative API key for the Headscale REST API. Anonymous requests to the REST API return HTTP 401; only the per instance API key is accepted. The API key is written to /root/headscale-credentials.txt (mode 0600, root only). No shared secret and no default login ever ships inside the image.

What is included:

  • The latest stable Headscale release (single Go binary), run under systemd as the unprivileged headscale system user

  • The Headscale control API and REST API bound to loopback only (127.0.0.1:8080) — never directly network exposed

  • nginx terminating TLS on :443 and reverse proxying the control protocol and REST API, tuned for the Tailscale long poll map stream (unbuffered, 900 second timeouts)

  • Port :80 returns a 301 redirect to HTTPS for every path except an unauthenticated /healthz endpoint (HTTP 200) for load balancer probes

  • A per instance API key generated on first boot and stored in /root/headscale-credentials.txt (0600) — no shared secret, no default login

  • A self signed TLS certificate regenerated per VM on first boot (SAN includes the VM public IP + hostname) — replace it with your own CA signed certificate for production

  • MagicDNS, an embedded SQLite state store, and sane defaults for IP allocation from the Tailscale 100.64.0.0/10 and fd7a:115c:a1e0::/48 ranges

  • Ubuntu 24.04 LTS base with latest security patches applied at build time

  • Azure Linux Agent for seamless cloud integration and SSH key injection

  • 24/7 cloudimg support with guaranteed 24 hour response SLA

Prerequisites

  • Active Azure subscription, SSH public key, VNet + subnet in target region

  • Subscription to the Headscale listing on Azure Marketplace

  • One or more machines running the Tailscale client that you want to join to your network

Recommended virtual machine size: Standard_B2s (2 vCPU, 4 GB RAM) is ample for a control server coordinating dozens of nodes. Larger fleets can use Standard_D2s_v5 or larger. The control server relays only coordination traffic, not your data plane: nodes connect directly to each other.

Step 1: Deploy from the Azure Portal

Search Headscale in Marketplace, select the cloudimg publisher, click Create. NSG rules: TCP 22 (admin), TCP 443 (control server over HTTPS) and TCP 80 (HTTP to HTTPS redirect and health probe) from the networks your Tailscale clients connect from.

Step 2: Deploy from the Azure CLI

RG="headscale-prod"; LOCATION="eastus"; VM_NAME="headscale-01"
GALLERY_IMAGE_ID="/subscriptions/<sub-id>/resourceGroups/azure-cloudimg/providers/Microsoft.Compute/galleries/cloudimgGallery/images/headscale-ubuntu-24-04/versions/<version>"
SSH_KEY="$(cat ~/.ssh/id_rsa.pub)"
az group create --name "$RG" --location "$LOCATION"
az network vnet create -g "$RG" --name headscale-vnet --address-prefix 10.100.0.0/16 --subnet-name headscale-subnet --subnet-prefix 10.100.1.0/24
az network nsg create -g "$RG" --name headscale-nsg
az network nsg rule create -g "$RG" --nsg-name headscale-nsg --name allow-ssh --priority 100 \
  --source-address-prefixes "<your-mgmt-cidr>" --destination-port-ranges 22 --access Allow --protocol Tcp
az network nsg rule create -g "$RG" --nsg-name headscale-nsg --name allow-https --priority 110 \
  --destination-port-ranges 443 --access Allow --protocol Tcp
az network nsg rule create -g "$RG" --nsg-name headscale-nsg --name allow-http --priority 120 \
  --destination-port-ranges 80 --access Allow --protocol Tcp
az vm create -g "$RG" --name "$VM_NAME" --image "$GALLERY_IMAGE_ID" \
  --size Standard_B2s --storage-sku StandardSSD_LRS \
  --admin-username azureuser --ssh-key-values "$SSH_KEY" \
  --vnet-name headscale-vnet --subnet headscale-subnet --nsg headscale-nsg --public-ip-sku Standard

Step 3: Verify the Headscale and nginx services

Connect over SSH, then confirm both services are active and that Headscale is bound to loopback only. In the listener output, 127.0.0.1:8080 (Headscale) is loopback only, while nginx listens publicly on :443 and :80:

sudo systemctl status headscale nginx --no-pager | head -14
ss -tln | grep -E ':8080|:443|:80 '

headscale.service and nginx.service both active (running); ss shows Headscale bound to 127.0.0.1:8080 loopback only, with nginx public on :443 and :80

Step 4: Retrieve your per instance API key

The per instance API key and control server URL are generated on the first boot and stored in a root only file. Read it over SSH:

sudo cat /root/headscale-credentials.txt

The file contains the control server URL (https://<your public ip>), the per instance REST API key and quick start commands for registering a node. Store the API key somewhere safe: it is unique to this VM.

The per instance headscale-credentials.txt file showing the control server URL over HTTPS and the per instance API key generated on first boot, with quick start commands for querying the API and registering a node

Step 5: Check the health endpoint and confirm the API rejects anonymous access

The /health and /healthz endpoints are served without authentication (ideal for load balancer and Azure health probes) and return HTTP 200. The REST API, in contrast, requires the per instance API key: an anonymous request returns HTTP 401, and only the key returns 200. The self signed certificate lives at /etc/nginx/tls/headscale.crt, so we pass it with --cacert:

CA=/etc/nginx/tls/headscale.crt
curl -s -o /dev/null -w 'health: HTTP %{http_code}\n' --cacert $CA https://127.0.0.1/health
KEY=$(sudo grep '^headscale.api_key=' /root/headscale-credentials.txt | cut -d= -f2-)
echo "# Anonymous REST API request (no key) must be rejected:"
curl -s -o /dev/null -w 'anonymous /api/v1/apikey -> HTTP %{http_code}\n' --cacert $CA https://127.0.0.1/api/v1/apikey
echo "# Authenticated request with the per instance API key:"
curl -s -o /dev/null -w 'authenticated /api/v1/apikey -> HTTP %{http_code}\n' -H "Authorization: Bearer $KEY" --cacert $CA https://127.0.0.1/api/v1/apikey
echo "# List registered nodes over the REST API:"
curl -s -H "Authorization: Bearer $KEY" --cacert $CA https://127.0.0.1/api/v1/node

The health endpoint returns HTTP 200, the anonymous API request returns HTTP 401, and the authenticated request returns HTTP 200 with the node list as JSON. There is no anonymous access to the control API.

curl of /health returns HTTP 200 over TLS; an anonymous /api/v1/apikey request returns HTTP 401; the same request with the per instance Bearer API key returns HTTP 200 and the /api/v1/node list as JSON

Step 6: Create a user and an operator generated pre auth key

Machines join the network with a pre auth key that you generate. No pre auth key ships in the image: you mint one on demand. Create a user (a namespace for a set of machines), then generate a reusable pre auth key for it. The Headscale CLI talks to the running server over a local unix socket, so these commands run directly on the VM:

sudo headscale users create ops
sudo headscale users list
sudo headscale preauthkeys create --user 1 --reusable --expiration 24h

headscale users list shows each user with a numeric id. Pass that id to preauthkeys create. The command prints a pre auth key beginning with hskey-auth-; copy it for the next step. Use --expiration to bound how long the key can be used and drop --reusable for a single use key.

Step 7: Join a machine to your network

On any machine running the Tailscale client, point it at your control server with the pre auth key from Step 6. Because the control server uses a self signed certificate, first trust that certificate on the client (or replace it with your own CA signed certificate, see Step 9). Replace <public-ip> with your VM public IP or DNS name.

Copy the certificate from the VM and install it into the client trust store:

scp azureuser@<public-ip>:/etc/nginx/tls/headscale.crt headscale.crt
sudo cp headscale.crt /usr/local/share/ca-certificates/headscale.crt
sudo update-ca-certificates

Then bring the client up against your control server:

sudo tailscale up --login-server https://<public-ip> --authkey <your-preauth-key>

Back on the control server, the new machine now appears in the node list with an address from the 100.64.0.0/10 range:

sudo headscale nodes list

The screenshot below shows the full flow on the control server: a user and a pre auth key are minted, a Tailscale client registers with that key, and headscale nodes list shows the joined node online with its 100.64 address.

The control server side of a node joining: headscale users create and preauthkeys create mint a pre auth key, and after the client runs tailscale up, headscale nodes list shows the registered node online with a 100.64.0.0/10 tailaddress

Step 8: How the control server is wired

The Headscale configuration at /etc/headscale/config.yaml binds the control API to loopback only, sets the per VM server URL, and allocates addresses from the Tailscale ranges. nginx is the only network facing surface: it terminates TLS and reverse proxies to Headscale with the directives the control protocol needs.

grep -E '^server_url|^listen_addr|base_domain|v4:|v6:' /etc/headscale/config.yaml
grep -nE 'listen 443 ssl|ssl_certificate|proxy_pass|proxy_buffering|proxy_read_timeout|return 301' /etc/nginx/sites-available/cloudimg-headscale

The output shows Headscale on 127.0.0.1:8080, the server_url set to your VM public URL, the 100.64.0.0/10 and fd7a:115c:a1e0::/48 allocation ranges, and the nginx TLS + reverse proxy directives (listen 443 ssl, ssl_certificate, proxy_pass to 127.0.0.1:8080, proxy_buffering off, proxy_read_timeout 900s).

Step 9: Use your own domain and a trusted certificate (production)

The self signed certificate is a convenience for getting started. For production, front the control server with your own DNS name and a CA signed certificate so clients trust it without any manual step:

  • Point a DNS record (for example headscale.example.com) at the VM public IP.

  • Replace /etc/nginx/tls/headscale.crt and /etc/nginx/tls/headscale.key with your CA signed certificate and key (for example from Let's Encrypt or your internal CA), update server_url in /etc/headscale/config.yaml to https://headscale.example.com, then run sudo nginx -t && sudo systemctl reload nginx && sudo systemctl restart headscale.

  • Clients then join with sudo tailscale up --login-server https://headscale.example.com --authkey <key> and no certificate trust step.

Step 10: Manage nodes, routes and keys

Headscale gives you full control over the network from the CLI and REST API:

sudo headscale nodes list
sudo headscale nodes list-routes
sudo headscale apikeys list
sudo headscale preauthkeys list

Approve advertised subnet routes with sudo headscale nodes approve-routes, expire a node with sudo headscale nodes expire, and rotate the API key with sudo headscale apikeys create followed by sudo headscale apikeys expire.

Step 11: Server components

Component Version / Detail
Control server Headscale (latest stable release, baked reproducibly, see /opt/headscale/VERSION)
Control binding 127.0.0.1:8080 (loopback only, plain HTTP behind the proxy)
Front proxy nginx (TLS on :443, reverse proxy, :80 -> 301 https)
State store SQLite at /var/lib/headscale/db.sqlite
Address ranges IPv4 100.64.0.0/10, IPv6 fd7a:115c:a1e0::/48
Credential per instance REST API key, generated first boot, /root/headscale-credentials.txt (0600)
TLS certificate self signed, regenerated per VM first boot (SAN = public IP + hostname), /etc/nginx/tls/
Operating system Ubuntu 24.04 LTS (patched at build)
License BSD 3-Clause (Headscale, Copyright (c) 2020 Juan Font)

Step 12: Managing the Headscale service

sudo systemctl status headscale
sudo systemctl restart headscale
sudo journalctl -u headscale -f
sudo systemctl reload nginx

Network state lives under /var/lib/headscale and persists across service restarts and VM reboots.

Step 13: Security recommendations

  • Restrict the NSG. Allow TCP 443 (and 22 for admin) only from the networks your Tailscale clients connect from. The control server does not need to be reachable from the whole internet unless your clients are.

  • Rotate the API key. Generate a new key with sudo headscale apikeys create and expire the old one with sudo headscale apikeys expire. Update /root/headscale-credentials.txt accordingly.

  • Bound your pre auth keys. Prefer single use keys and short --expiration windows, and use per team users to keep node ownership clear.

  • Use a trusted certificate (Step 9) for production so clients validate the control server without a manual trust step.

  • Keep the OS patched. Unattended security upgrades remain enabled on the running VM.

  • Back up /var/lib/headscale to preserve your network state (users, nodes, keys and routes).

Step 14: Support and Licensing

Headscale is distributed under the BSD 3-Clause License (Copyright (c) 2020 Juan Font and contributors). This cloudimg image bundles the unmodified official release binary. cloudimg provides the packaging, hardening, per instance API key and TLS automation, and 24/7 support with a guaranteed 24 hour response SLA. Headscale is an independent open source project and is not affiliated with or endorsed by Tailscale Inc.

Deploy on Azure

Find Headscale on Ubuntu 24.04 on the Azure Marketplace, published by cloudimg. Deploy from the Portal or the Azure CLI as shown above.

Need Help?

Email support@cloudimg.co.uk for deployment help, configuration questions, or licensing enquiries.