Skip to main content

Headscale Stack

Headscale is an open-source, self-hosted implementation of the Tailscale control plane. Quave ONE ships it as a Solution, so you can run your own private mesh VPN — your own "tailnet" — inside your account in a few minutes, without depending on a third-party SaaS control plane and without paying per seat.

Devices that join the tailnet talk to each other over encrypted WireGuard tunnels, no matter where they are. The control plane only coordinates who may talk to whom; the traffic itself is peer to peer.

What you can do with it

  • Reach private services without exposing them. Databases, admin panels, and internal APIs stay off the public internet and are reachable only from the tailnet.
  • Give the team private access. Laptops join the VPN with a single key and get a stable private IP and a DNS name.
  • Bridge your account's private network. A subnet router advertises the internal cluster CIDRs into the tailnet, so a developer laptop can talk to your app environments by name.
  • Connect other clouds and CI. Any machine that runs the Tailscale client — another cloud, an on-prem server, a CI runner — can be a node.
  • Keep control of the data. Users, keys, nodes, and the access-control policy live in your own environment, on your own volume.

How the pieces fit together

ComponentImageRole
Headscale Serverdocker.io/quaveone/headscaleThe control plane. Registers devices, distributes keys, serves MagicDNS, and enforces the ACL policy. Keeps its state (SQLite database and noise key) on a persistent volume. Clients reach it over its public URL.
Headscale Admin UIdocker.io/quaveone/headscale-uiThe web console for day-2 operations: users, pre-auth keys, nodes, route approval, and the ACL policy. It talks to the server's REST API with an API key that stays server-side and never reaches the browser.
Tailscale Subnet RouterManaged Quave ONE presetAn optional stateless agent running inside your account. It registers as a node and advertises the account's private CIDRs, which is what lets tailnet clients reach your environments.

The server is the only component that must be reachable from the internet, because that is the URL VPN clients register against. The admin UI is a normal app environment: put it behind your usual access controls, or reach it through the VPN itself once the tailnet is up.

Which Solution to install

Headscale Stack

The recommended starting point. Installs the server and the admin UI together and wires them for you: after the server is ready, the install runs headscale apikeys create inside it and patches the resulting key into the UI's HEADSCALE_API_KEY. You get a working console with no manual key handling.

InputRequiredDefaultNotes
Public server URLNoThe default URL of the created server environmentThe public URL VPN clients use to reach Headscale.
MagicDNS base domainNotailnet.internalDomain suffix for tailnet device names.
The base domain must not be a parent of the server URL domain

If your server URL is https://vpn.example.com, the base domain cannot be example.com. Headscale refuses to start in that case, because the tailnet DNS would shadow the name clients need to reach the control plane. Keep the default tailnet.internal unless you have a specific reason to change it.

Headscale Server

Only the control plane, with the same two inputs as the stack. Choose it when you already run an admin UI elsewhere, or when you prefer to manage everything with the headscale CLI from the environment console.

Headscale UI

Only the admin console, pointed at an existing Headscale server.

InputRequiredNotes
Headscale server URLYesThe internal URL of your server environment, for example http://wa-hs-prod:8080. You can copy it from the Hosts section of the server environment. See Hosts.
Headscale API keyNoLeave empty to install with a placeholder, then create a key and update the env var as described below.

Tailscale Subnet Router

A stateless agent that joins the tailnet from inside your account and advertises private routes. It is a managed Quave ONE component: a single replica, no volume, no backups. It only needs a pre-auth key, plus your Headscale URL if you are not using the official Tailscale service.

InputRequiredNotes
Auth keyYesA pre-auth key generated in the admin UI, stored as the secret env var TS_AUTHKEY. Use a reusable key so the agent can re-register after a restart.
Login serverNoYour Headscale public URL. Leave empty to register against the official Tailscale control plane instead.

Step 1 — Install the stack

  1. Start a new project in your account and choose Solutions.
  2. Pick Headscale Stack.
  3. Leave both inputs empty to accept the defaults, or set your own public URL and base domain.
  4. Confirm the install. Quave ONE creates the server first, waits for it to become ready, mints the API key, then creates and configures the admin UI.
  5. Copy the generated admin password shown when the install finishes. It is also stored as the secret env var ADMIN_PASSWORD on the admin UI environment, so you can read it there later.

What gets created:

  • a server environment on port 8080, with a 1 GB volume mounted at /var/lib/headscale and a public host;
  • an admin UI environment on port 3000, with a public host, already pointing at the server's internal host.

If you installed Headscale Server and Headscale UI separately, create the API key yourself. Open the server environment's console and run:

headscale apikeys create --expiration 87600h

Copy the printed key and set it as HEADSCALE_API_KEY on the admin UI environment, then apply the change so the UI restarts with it.

Step 2 — Sign in to the admin console

Open the admin UI host in a browser and log in with the ADMIN_PASSWORD value. There is a single shared admin password today; the session is a short-lived httpOnly cookie.

The console has five pages: Users, Pre-auth keys, Nodes, ACL policy, and Docs.

Step 3 — Create a user

A user is the owner of devices and pre-auth keys. Create one user per person, or one per system account for automated agents.

  1. Go to Users.
  2. Click New user.
  3. Enter a name, for example engineering or ci, and click Create.

Renaming a user keeps its devices. Deleting a user requires removing its devices first.

Step 4 — Create a pre-auth key

Pre-auth keys let a device join the VPN without an interactive login. They are what you paste into tailscale up --authkey, or into the Auth key field when installing a Tailscale Subnet Router.

  1. Go to Pre-auth keys.
  2. Select the owning user in the User dropdown.
  3. Click New key and choose the options:
OptionWhat it means
ReusableThe key can register multiple devices. A single-use key is consumed by the first device that uses it.
EphemeralDevices registered with this key are removed automatically shortly after they go offline. Good for short-lived containers and CI runners.
ExpirationHow long the key itself can be used to register devices: 1 hour, 24 hours, 7 days, or 90 days. It does not expire devices that already registered with it.
ACL tagsComma-separated tags such as tag:quave-one-router applied to every device registered with the key. Tags must be listed under tagOwners in the ACL policy, and they are how route auto-approval targets a device.
  1. Click Create key.
The key is shown only once

Copy the key from the confirmation dialog immediately. It is never displayed again — the list only shows a prefix. If you lose it, expire the key and create a new one.

For a Tailscale Subnet Router, use a reusable key. A single-use key is consumed on the first registration, and the agent will fail with invalid pre auth key the next time the pod restarts.

You can revoke a key at any time with Expire in the key list. Expiring a key stops new registrations; it does not disconnect devices that already joined.

Step 5 — Register a device as a node

Every registered device is a node. On the client machine:

# Install the Tailscale client, then point it at your Headscale
tailscale up \
--login-server=https://your-headscale-url \
--authkey=YOUR_PREAUTH_KEY \
--accept-dns

For a container:

docker run -d --name ts-agent \
-e TS_AUTHKEY=YOUR_PREAUTH_KEY \
-e TS_EXTRA_ARGS='--login-server=https://your-headscale-url' \
-e TS_USERSPACE=true \
tailscale/tailscale:latest

Inside Quave ONE, install the Tailscale Subnet Router Solution instead of running the container yourself. It wires the auth key and the login server for you and confines the agent with a deny-by-default network policy.

The device then appears on the Nodes page, where you can:

  • see its name, owning user, tailnet IPs, last-seen timestamp, and online status;
  • Rename it, which also changes its MagicDNS name;
  • Expire it, which invalidates its node key and forces it to re-authenticate;
  • Delete it.

Step 6 — Approve subnet routes

A node started with --advertise-routes=10.0.0.0/16 offers those CIDRs to the tailnet, but traffic does not flow until each route is approved. This is deliberate: advertising a route is a request, not a grant.

  1. Go to Nodes. The Routes column shows an approved/advertised counter for any node offering routes.
  2. Click the counter to open the routes dialog.
  3. Each advertised CIDR is listed as approved or pending. Click Approve or Unapprove to toggle it.

Exit nodes follow the same flow: a node advertising 0.0.0.0/0 stays pending until you approve it explicitly.

To skip the manual step, add an autoApprovers entry to the ACL policy. Routes advertised by a device carrying the listed tag are approved automatically at registration.

Step 7 — Edit the ACL policy

The policy is Tailscale-compatible HuJSON — JSON that allows comments and trailing commas. Its main blocks:

BlockPurpose
aclsAccept rules mapping a source to a destination and port. Default-deny: anything not listed is blocked.
groupsNamed sets of users you can use in rules, such as group:admins.
tagOwnersWhich users may apply each tag to devices. A tag must be owned before a pre-auth key can carry it.
autoApproversRoutes (per CIDR) and exit nodes approved automatically when advertised by a device with the listed tag.

A minimal policy that owns a router tag, auto-approves its subnet, and allows all tailnet traffic:

{
"tagOwners": { "tag:quave-one-router": ["alice@"] },
"autoApprovers": {
"routes": { "10.22.0.0/16": ["tag:quave-one-router"] },
},
"acls": [
{ "action": "accept", "src": ["*"], "dst": ["*:*"] },
],
}
Two ways to set the policy

The Solution installs the server in file policy mode, where the policy comes from the HEADSCALE_POLICY_CONTENT env var and an allow-all policy is generated when that var is unset. In this mode the ACL policy page in the admin UI can read the policy but cannot save it.

  • File mode (default): paste the full HuJSON into the HEADSCALE_POLICY_CONTENT env var of the server environment and apply the change. The entrypoint rewrites the policy file on every restart, so the env var is the source of truth — never edit the file on disk.
  • Database mode: if you want to edit the policy from the UI, switch the server to a full custom config with HEADSCALE_CONFIG_CONTENT and set policy.mode: database. Full-config mode ignores the individual HEADSCALE_* config env vars, so that config must be complete.

Step 8 — Configure split DNS

Split DNS makes VPN clients resolve specific zones through your cluster's internal DNS, while every other domain keeps using their normal resolver. That is what lets a laptop on the VPN reach an internal environment by name.

Your account has two internal zones, both derived from its Kubernetes namespace (wa-<account-slug>-<account-id>):

ZoneResolves
<namespace>.svc.zcloud.localApps, Functions, and Jobs
<namespace>.svc.db.zcloud.wsDatabases

List the zones you need in HEADSCALE_SPLIT_DNS_ZONES on the server environment, comma-separated, then apply the change. The resolver defaults to the container's own nameserver, which is the cluster DNS.

The rendered server config ends up looking like this:

dns:
nameservers:
split:
wa-example-xxxxxxxxxxxxxxxxx.svc.zcloud.local:
- 10.27.0.10
wa-example-xxxxxxxxxxxxxxxxx.svc.db.zcloud.ws:
- 10.27.0.10

From a client connected with --accept-dns, internal names under those zones then resolve normally:

# Resolve an app through the tailnet DNS
nslookup wa-myapp-prod.wa-example-xxxxxxxxxxxxxxxxx.svc.zcloud.local

# Reach it directly
curl http://wa-myapp-prod.wa-example-xxxxxxxxxxxxxxxxx.svc.zcloud.local:3000

# Connect to a managed database over the VPN
psql -h wa-mydb-prod.wa-example-xxxxxxxxxxxxxxxxx.svc.db.zcloud.ws -U app

Set the same zone list on the admin UI's SPLIT_DNS_ZONES env var to have its built-in Docs page display the live zones and a ready-to-copy example for your install.

Step 9 — Bridge the account network with a subnet router

To let tailnet clients reach your Quave ONE environments:

  1. Create a reusable pre-auth key, tagged if you plan to use auto-approval.
  2. Install the Tailscale Subnet Router Solution in the same account, pasting that key and your Headscale public URL as the login server.
  3. Back in the admin UI, open Nodes and approve the routes the agent advertises — unless an autoApprovers rule already covers them.

The agent runs as a single stateless replica by design and has no volume and no backups. If you leave the advertised routes empty, it falls back to the region's pod and service CIDRs.

Environment variable reference

Headscale Server

The image supports two configuration modes. In parameter mode (the default, and what the Solution uses) the entrypoint renders the full config from individual variables. In full-content mode, HEADSCALE_CONFIG_CONTENT holds the complete config.yaml and every variable below is ignored.

VariableRequiredDefaultDescription
HEADSCALE_SERVER_URLYesPublic URL VPN clients use to reach this server.
HEADSCALE_LISTEN_PORTNo8080HTTP listen port.
HEADSCALE_BASE_DOMAINNotailnet.internalMagicDNS base domain.
HEADSCALE_GLOBAL_NAMESERVERSNo1.1.1.1,1.0.0.1Comma-separated default resolvers pushed to clients.
HEADSCALE_SPLIT_DNS_ZONESNoComma-separated zones resolved through the cluster resolver.
HEADSCALE_SPLIT_DNS_RESOLVERSNoThe pod's own nameserverComma-separated resolver IPs for the split zones.
HEADSCALE_CONFIG_CONTENTNoComplete config.yaml. Overrides every variable above.
HEADSCALE_POLICY_CONTENTNoAllow-allComplete policy.hujson.

Env var content is the source of truth: every restart overwrites the files on disk, so edit the variables, not the files.

Headscale Admin UI

VariableRequiredDefaultDescription
HEADSCALE_URLYesBase URL of the Headscale server, normally its internal host.
HEADSCALE_API_KEYYesAPI key used for every proxied call. Stays server-side.
ADMIN_PASSWORDYesPassword for the console login.
PORTNo3000Port the server listens on.
SESSION_TTL_SECONDSNo86400Session lifetime in seconds.
SPLIT_DNS_ZONESNoZones displayed on the built-in Docs page.
SPLIT_DNS_RESOLVERNoThe pod's own nameserverResolver IP shown in the Docs page examples.

The admin UI exits at boot if a required variable is missing.

Managing Headscale from the console

Everything the UI does is also available through the headscale CLI in the server environment's console:

headscale users list
headscale users create engineering

headscale preauthkeys create --user 1 --reusable --expiration 90d
headscale preauthkeys list --user 1

headscale nodes list
headscale nodes expire --identifier 3

headscale apikeys create --expiration 87600h
headscale apikeys list

Troubleshooting

SymptomCause and fix
The server pod will not start and the logs mention the base domainThe MagicDNS base domain is a parent of the server URL domain. Change HEADSCALE_BASE_DOMAIN to an unrelated suffix such as tailnet.internal.
The subnet router restarts with invalid pre auth keyThe key was single-use and is already consumed, or it expired. Create a fresh reusable key and update the agent's TS_AUTHKEY env var.
The admin UI shows an authentication error on every pageHEADSCALE_API_KEY is missing, still the install placeholder, or expired. Create a new key with headscale apikeys create and update the env var.
Saving the ACL policy in the UI failsThe server is in file policy mode. Set the policy through HEADSCALE_POLICY_CONTENT, or switch the server to database policy mode.
A client is connected but cannot reach a private serviceThe route is advertised but not approved. Approve it on the Nodes page, or add an autoApprovers rule for the agent's tag.
A client resolves public names but not internal onesThe client is not using the tailnet DNS. Connect with --accept-dns, and confirm HEADSCALE_SPLIT_DNS_ZONES lists the zone.