Headscale
Headscale is a self-hosted Tailscale control server. Headplane is its web admin UI. This stack runs both on one public hostname, with sign-in through Authentik over OIDC. Once it runs, your nodes and devices can join a tailnet that you control.
This page is about the stack. For connecting machines and Clouduler nodes to a tailnet, see the Tailscale and Headscale guide, and for building a cluster across several sites on top of Headscale, the multi-site quick start.
What you'll learn
- Two services behind one hostname, split by
path_prefix. - Writable configs (
write: true) that a service edits and Clouduler keeps in the cluster. - Mounting the Docker socket into a container.
- A CORS middleware and
preserve_host. - Three configuration files bundled with the stack.
- OIDC clients configured against Authentik, and a secret that only exists after the first start.
Prerequisites
Cluster. Networks public (domain example.com) and wan (egress). Drive apps. See the cluster prerequisites.
DNS. headscale.example.com, a public route. Clients must reach it from the internet, so the name must sit inside a Cloudflare zone if you want Clouduler to publish it.
Authentik. The Authentik stack must exist first. Create an OAuth2/OpenID provider and application with the slug headscale there, using the redirect URIs that the Headscale and Headplane documentation give for your versions. This example points both Headscale and Headplane at that one application, so with a single provider you paste the same client secret into both secrets below.
Secrets. All four are referenced, so all four must exist before the first deploy.
| Name | What it is | Where the value comes from |
|---|---|---|
headplane_api_key | API key Headplane uses to call Headscale. | Created inside Headscale after the first start. Until then, Paste string with a placeholder such as placeholder, so the deploy passes. |
headplane_cookie_secret | Signs Headplane's session cookie. | Generate, alphanumeric, 32. Check Headplane's requirement for the length. |
headplane_oidc_client_secret | OIDC client secret for Headplane. | Paste string, copied from the Authentik provider. |
headscale_oidc_client_secret | OIDC client secret for Headscale. | Paste string, copied from the Authentik provider. |
Configs.
| Name | Source file | Content |
|---|---|---|
headscale_config | ./config/config.yaml | Headscale's main config |
headscale_policy | ./config/policy.hujson | Access policy |
headplane_config | ./config/headplane.yaml | Headplane config |
Volumes. apps/headscale-data (database, Noise key, DERP keys) and apps/headscale-headplane-data (Headplane data).
Stack YAML
services:
headplane:
image: ghcr.io/tale/headplane:latest-shell
volumes:
- apps/headscale-headplane-data:/var/lib/headplane
networks:
- public
- wan
configs:
- source: headscale_config
target: /etc/headscale/config.yaml
write: true
- source: headplane_config
target: /etc/headplane/config.yaml
write: true
secrets:
- source: headplane_cookie_secret
env: HEADPLANE_SERVER__COOKIE_SECRET
- source: headplane_api_key
env: HEADPLANE_HEADSCALE__API_KEY
- source: headplane_oidc_client_secret
env: HEADPLANE_OIDC__CLIENT_SECRET
restart: unless-stopped
deploy:
replicas: 1
resources:
limits:
memory: 256M
# The image ships its own healthcheck, so none is set here.
x-clouduler:
docker_socket:
target: /var/run/docker.sock
read_only: false
proxy:
- host: headscale
network: public
port: 3000
path_prefix: /admin
preserve_host: true
headscale:
image: headscale/headscale:latest
command: serve
volumes:
- apps/headscale-data:/var/lib/headscale
networks:
- public
- wan
configs:
- source: headscale_config
target: /etc/headscale/config.yaml
- source: headscale_policy
target: /etc/headscale/policy.hujson
secrets:
- source: headscale_oidc_client_secret
target: /run/secrets/headscale_oidc_client_secret
restart: unless-stopped
deploy:
replicas: 1
resources:
limits:
memory: 256M
x-clouduler:
proxy:
- host: headscale
network: public
port: 25725
middlewares:
- cors:
origins:
- https://headscale.example.com
methods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
headers:
- Authorization
- Content-Type
max_age: 1m 40s
networks:
# Hardened: only Authentik (OIDC, a public route) is called on public. The
# upstream DERP map is the one thing fetched from outside.
public:
allowed:
- auth.example.com
wan:
allowed:
- controlplane.tailscale.com
configs:
headplane_config:
file: ./config/headplane.yaml
headscale_config:
file: ./config/config.yaml
headscale_policy:
file: ./config/policy.hujson
secrets:
headplane_api_key:
headplane_cookie_secret:
headplane_oidc_client_secret:
headscale_oidc_client_secret:
Config files
Put these three files in a config/ folder next to headscale.yaml.
server_url: https://headscale.example.com
listen_addr: 0.0.0.0:25725
metrics_listen_addr: 127.0.0.1:9090
grpc_listen_addr: 127.0.0.1:50443
grpc_allow_insecure: false
noise:
private_key_path: /var/lib/headscale/noise_private.key
prefixes:
v4: 100.64.0.0/10
v6: ""
allocation: sequential
derp:
urls:
- https://controlplane.tailscale.com/derpmap/default
auto_update_enabled: true
update_frequency: 3h
server:
enabled: false
region_id: 999
region_code: headscale
region_name: Headscale Embedded DERP
stun_listen_addr: 0.0.0.0:3478
private_key_path: /var/lib/headscale/derp_server_private.key
automatically_add_embedded_derp_region: true
ipv4: ""
ipv6: ""
database:
type: sqlite
sqlite:
path: /var/lib/headscale/db.sqlite
write_ahead_log: true
log:
level: info
format: text
dns:
magic_dns: true
base_domain: ts.example.com
override_local_dns: true
nameservers:
global:
- 1.1.1.1
- 1.0.0.1
split:
home.example.com:
- 192.168.1.53
- 192.168.1.54
extra_records: []
unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"
logtail:
enabled: false
taildrop:
enabled: true
policy:
mode: file
path: /etc/headscale/policy.hujson
oidc:
only_start_if_oidc_is_available: false
issuer: "https://auth.example.com/application/o/headscale/"
# The client_id is the Authentik application slug. Create the application in
# Authentik with this slug. The value is not sensitive.
client_id: "headscale"
client_secret_path: /run/secrets/headscale_oidc_client_secret
pkce:
enabled: true
scope:
- openid
- profile
- email
# Authentik does not always send email_verified for manually created users.
email_verified_required: false
server:
host: "0.0.0.0"
port: 3000
base_url: "https://headscale.example.com/admin"
cookie_secure: true
data_path: "/var/lib/headplane"
# cookie_secret and headscale.api_key are injected at runtime from secrets
headscale:
url: "http://headscale:25725"
config_path: "/etc/headscale/config.yaml"
integration:
agent:
enabled: true
docker:
enabled: true
socket: "unix:///var/run/docker.sock"
oidc:
issuer: "https://auth.example.com/application/o/headscale/"
client_id: "headscale"
use_pkce: true
scope: "openid profile email"
{
"tagOwners": {
"tag:headplane-agent": ["admin@"]
},
"acls": [
{ "action": "accept", "src": ["admin@"], "dst": ["*:*"] },
{ "action": "accept", "src": ["autogroup:member"], "dst": ["servers@:*"] },
{ "action": "accept", "src": ["servers@"], "dst": ["servers@:*"] },
{ "action": "accept", "src": ["servers@", "tag:headplane-agent"], "dst": ["tag:headplane-agent:*"] },
{ "action": "accept", "src": ["tag:headplane-agent"], "dst": ["servers@:*"] },
{ "action": "accept", "src": ["autogroup:member"], "dst": ["autogroup:internet:*"] }
]
}
The prefixes.v4 range 100.64.0.0/10 is the standard tailnet address range. base_domain (ts.example.com) is the MagicDNS name and must differ from the public name in server_url. The split DNS entry is a single example: replace it with your own resolver and domain.
Walkthrough
- Two services, one host. Both routes use
host: headscaleonpublic. Headplane haspath_prefix: /adminand Headscale has none, so/admingoes to Headplane and everything else goes to Headscale. See Proxy and TLS. preserve_host: trueforwards the client'sHostheader to Headplane, which the UI needs to build its links.write: trueon both configs of Headplane: Headplane can edit them and Clouduler writes the edits back to the cluster. Only one instance may hold a writable config, which is why Headplane hasreplicas: 1. Headscale mountsheadscale_configread-only. See Configs and secrets.- Secrets as
env:for Headplane. Headscale gets its OIDC secret as a file, and the explicittargetmatchesclient_secret_pathin the config. docker_socketmounts the node's Docker socket into Headplane at/var/run/docker.sock, so it can see and control containers.modedefaults tolocal, the node's real socket.corsmiddleware on the Headscale route allows the listed methods and headers from the dashboard origin.max_age: 1m 40sis the preflight cache time.networks.public.allowed: [auth.example.com]: on the public network, the stack may call only Authentik.wan.allowed: [controlplane.tailscale.com]: the only outside fetch is the upstream DERP map.
Deploy
- Web UI
- CLI / config
- Open Settings, Secrets and configs. Create
headplane_cookie_secretwith Generate (alphanumeric,32). Create the two OIDC secrets and the placeholderheadplane_api_keywith Paste string. - Open Stacks, Deploy stack, name it
headscale, paste the YAML. - In Bundled files, fill
headscale_config,headscale_policyandheadplane_configwith the three files above. - Click Deploy headscale once the spec is valid.
mkdir -p headscale/config && cd headscale
# save headscale.yaml and the three files in config/
tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32 | clouduler secret set headplane_cookie_secret
printf '%s' 'placeholder' | clouduler secret set headplane_api_key
printf '%s' 'the-authentik-secret' | clouduler secret set headplane_oidc_client_secret
printf '%s' 'the-authentik-secret' | clouduler secret set headscale_oidc_client_secret
clouduler stack deploy ./headscale.yaml
First run
-
Let the stack start once with the placeholder API key. Check
clouduler stack unhealthyand the logs. -
Create a Headscale user for the admin you will sign in as:
clouduler exec headscale.headscale.0 -- headscale users create admin -
Create the API key Headplane needs, then store it in the secret (this overwrites the placeholder):
clouduler exec headscale.headscale.0 -- headscale apikeys create --expiration 90dprintf '%s' "THE_KEY" | clouduler secret set headplane_api_key -
Restart the stack so Headplane reads the new value:
clouduler stack restart headscale. -
Open
https://headscale.example.com/adminand sign in with Authentik.clouduler exec headscale.headscale.0 -- headscale users listshould show your user. -
Join a machine:
tailscale up --login-server https://headscale.example.com. The Tailscale and Headscale guide covers installing the client and pointing Clouduler nodes at the tailnet.
Quirks
- The policy names
admin@. Headscale matches users by name, so the user must exist asadminbefore the policy takes effect. Check the name after your first OIDC login. - Config edits from Headplane take effect after Headscale restarts.
- Headscale's gRPC and metrics ports stay on
127.0.0.1. Only the API port goes through the proxy. derp.server.enabled: false: the stack uses Tailscale's public relays, not an embedded one.- Memory. 256M each is enough for a small tailnet. Raise it as it grows.
- Pin the images. Pin both
latesttags once it works.