Publish privacy-safe Headscale deployment examples

This commit is contained in:
lihato 2026-08-05 11:55:00 +08:00
commit 08bb76a26b
8 changed files with 392 additions and 0 deletions

29
.gitignore vendored Normal file
View file

@ -0,0 +1,29 @@
# Privacy-first public repository: ignore every file unless explicitly allowed.
*
# Public documentation and Compose template.
!.gitignore
!README.md
!docker-compose.yml.example
# Headscale templates.
!headscale/
headscale/*
!headscale/config/
headscale/config/*
!headscale/config/*.example
# Headplane templates.
!headplane/
headplane/*
!headplane/config/
headplane/config/*
!headplane/config/*.example
# Traefik templates.
!traefik/
traefik/*
!traefik/traefik.yml.example
!traefik/dynamic/
traefik/dynamic/*
!traefik/dynamic/*.example

111
README.md Normal file
View file

@ -0,0 +1,111 @@
# Headscale + Headplane + Traefik
This repository contains privacy-safe example files for running a self-hosted
Headscale control plane with the Headplane web UI behind Traefik and automatic
Let's Encrypt certificates.
Only `README.md`, `.gitignore`, and files ending in `.example` are published.
Real configuration, credentials, databases, generated keys, certificates,
backups, and container images are ignored by design.
## Architecture
- **Headscale** provides the Tailscale-compatible control plane and embedded DERP/STUN.
- **Headplane** provides the administration UI at `/admin`.
- **Traefik** terminates HTTPS and routes requests to Headscale or Headplane.
The example pins Headscale `v0.29.2`, Headplane `0.7.0`, and Traefik `v3.7.9`.
## Requirements
- A Linux server with Docker Engine and Docker Compose v2
- A public IPv4 address
- A domain such as `headscale.example.com` pointing to that address
- Inbound `80/tcp`, `443/tcp`, and `3478/udp` allowed by the firewall
## Prepare configuration
Clone the repository, then create the ignored runtime files from the examples:
```bash
cp docker-compose.yml.example docker-compose.yml
cp headscale/config/config.yaml.example headscale/config/config.yaml
cp headscale/config/acl.hujson.example headscale/config/acl.hujson
cp headplane/config/config.yaml.example headplane/config/config.yaml
cp traefik/traefik.yml.example traefik/traefik.yml
cp traefik/dynamic/headscale.yml.example traefik/dynamic/headscale.yml
mkdir -p headscale/data headscale/run headplane/data traefik/letsencrypt
touch traefik/letsencrypt/acme.json
chmod 600 traefik/letsencrypt/acme.json
```
Replace the example values in the copied files:
- `headscale.example.com`: the public control-plane domain
- `dns.example.com`: the MagicDNS base domain
- `203.0.113.10`: the server's public IPv4 address
- `admin@example.com`: the Let's Encrypt account email
- `REPLACE_WITH_OPENSSL_RAND_HEX_32`: output of `openssl rand -hex 32`
Do not put real credentials in a file ending in `.example`.
## Start and configure
Validate the Compose model, then start Headscale and Traefik first:
```bash
docker compose config --quiet
docker compose up -d headscale traefik
```
Create an API key for Headplane:
```bash
docker compose exec headscale headscale apikeys create --expiration 365d
```
Set the resulting key as `headscale.api_key` in the ignored
`headplane/config/config.yaml`, set a random cookie secret, and start Headplane:
```bash
docker compose up -d headplane
docker compose ps
```
Headscale is served at `https://headscale.example.com`; Headplane is served at
`https://headscale.example.com/admin`.
## Add a user and client
```bash
docker compose exec headscale headscale users create default
docker compose exec headscale headscale preauthkeys create \
--user default --reusable --expiration 24h
```
Join a client with the generated pre-auth key:
```bash
tailscale up \
--login-server https://headscale.example.com \
--auth-key YOUR_PREAUTH_KEY
```
## Optional OIDC
The Headscale and Headplane examples include commented OIDC sections. Copy and
uncomment them only in the ignored runtime configuration files. Keep the OIDC
client secret out of Git and use the same public issuer and client details in
both services.
## Operations
```bash
docker compose logs -f headscale headplane traefik
docker compose pull
docker compose up -d
```
Back up `headscale/data`, `headplane/data`, and `traefik/letsencrypt` securely.
These directories contain private state and are intentionally never published.

View file

@ -0,0 +1,61 @@
name: headscale
services:
headscale:
image: headscale/headscale:v0.29.2
container_name: headscale
restart: unless-stopped
command: ["serve", "-c", "/etc/headscale/config.yaml"]
volumes:
- ./headscale/config:/etc/headscale:ro
- ./headscale/data:/var/lib/headscale
- ./headscale/run:/var/run/headscale
expose:
- "8080"
ports:
- "3478:3478/udp"
labels:
- me.tale.headplane.target=headscale
networks:
- headscale-net
headplane:
image: ghcr.io/tale/headplane:0.7.0
container_name: headplane
restart: unless-stopped
depends_on:
- headscale
volumes:
- ./headplane/config/config.yaml:/etc/headplane/config.yaml:ro
- ./headplane/data:/var/lib/headplane
- ./headscale/config:/etc/headscale:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
expose:
- "3000"
networks:
- headscale-net
traefik:
image: traefik:v3.7.9
container_name: traefik
restart: unless-stopped
command:
- --configFile=/etc/traefik/traefik.yml
healthcheck:
test: ["CMD", "traefik", "healthcheck", "--ping"]
interval: 5s
timeout: 2s
retries: 10
ports:
- "80:80"
- "443:443"
volumes:
- ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- ./traefik/dynamic:/etc/traefik/dynamic:ro
- ./traefik/letsencrypt:/letsencrypt
networks:
- headscale-net
networks:
headscale-net:
driver: bridge

View file

@ -0,0 +1,37 @@
server:
host: 0.0.0.0
port: 3000
base_url: https://headscale.example.com
cookie_secret: REPLACE_WITH_OPENSSL_RAND_HEX_32
cookie_secure: true
data_path: /var/lib/headplane
headscale:
url: http://headscale:8080
public_url: https://headscale.example.com
config_path: /etc/headscale/config.yaml
config_strict: true
api_key: REPLACE_WITH_HEADSCALE_API_KEY
# Optional OIDC example. Uncomment only in the ignored config.yaml file.
# oidc:
# issuer: https://auth.example.com/application/o/headscale/
# client_id: REPLACE_WITH_OIDC_CLIENT_ID
# client_secret: REPLACE_WITH_OIDC_CLIENT_SECRET
# use_pkce: true
# scope: openid email profile groups headplane_role
# default_role: member
# role_claim: headplane_role
integration:
agent:
enabled: false
docker:
enabled: true
container_label: me.tale.headplane.target=headscale
socket: unix:///var/run/docker.sock
kubernetes:
enabled: false
pod_name: ""
proc:
enabled: false

View file

@ -0,0 +1,7 @@
{
// Permissive starter policy. Replace it with least-privilege rules.
"acls": [
{ "action": "accept", "src": ["*"], "dst": ["*:*"] }
],
"ssh": []
}

View file

@ -0,0 +1,89 @@
server_url: https://headscale.example.com
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090
grpc_listen_addr: 127.0.0.1:50443
grpc_allow_insecure: false
private_key_path: /var/lib/headscale/private.key
noise:
private_key_path: /var/lib/headscale/noise_private.key
prefixes:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48
allocation: sequential
derp:
server:
enabled: true
region_id: 999
region_code: headscale
region_name: Headscale Embedded DERP
verify_clients: true
stun_listen_addr: 0.0.0.0:3478
private_key_path: /var/lib/headscale/derp_server_private.key
automatically_add_embedded_derp_region: true
# Replace this documentation-only address with the server's public IPv4.
ipv4: 203.0.113.10
urls:
- https://controlplane.tailscale.com/derpmap/default
paths: []
auto_update_enabled: false
disable_check_updates: false
node:
expiry: 0
ephemeral:
inactivity_timeout: 1h
database:
type: sqlite
debug: false
gorm:
prepare_stmt: true
parameterized_queries: true
skip_err_record_not_found: true
slow_threshold: 1000
sqlite:
path: /var/lib/headscale/db.sqlite
write_ahead_log: true
wal_autocheckpoint: 1000
# TLS is terminated by Traefik.
acme_url: https://acme-v02.api.letsencrypt.org/directory
acme_email: ""
tls_letsencrypt_hostname: ""
dns:
magic_dns: true
base_domain: dns.example.com
nameservers:
global:
- 1.1.1.1
- 8.8.8.8
policy:
mode: file
path: /etc/headscale/acl.hujson
log:
level: info
format: text
unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"
logtail:
enabled: false
taildrop:
enabled: true
# Optional OIDC example. Uncomment only in the ignored config.yaml file.
# oidc:
# only_start_if_oidc_is_available: false
# issuer: https://auth.example.com/application/o/headscale/
# client_id: REPLACE_WITH_OIDC_CLIENT_ID
# client_secret: REPLACE_WITH_OIDC_CLIENT_SECRET
# scope: [openid, profile, email, groups]
# allowed_groups: [headscale-users]
# pkce:
# enabled: true
# method: S256

View file

@ -0,0 +1,25 @@
http:
routers:
headplane:
rule: "Host(`headscale.example.com`) && PathPrefix(`/admin`)"
entryPoints: [websecure]
service: headplane
priority: 100
tls:
certResolver: le
headscale:
rule: "Host(`headscale.example.com`)"
entryPoints: [websecure]
service: headscale
priority: 10
tls:
certResolver: le
services:
headscale:
loadBalancer:
servers:
- url: http://headscale:8080
headplane:
loadBalancer:
servers:
- url: http://headplane:3000

View file

@ -0,0 +1,33 @@
api:
dashboard: false
ping: {}
log:
level: INFO
accessLog: {}
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ":443"
providers:
file:
directory: /etc/traefik/dynamic
watch: true
certificatesResolvers:
le:
acme:
email: admin@example.com
storage: /letsencrypt/acme.json
httpChallenge:
entryPoint: web