commit 08bb76a26bb577754b02ffa9c9b5dd3117527b37 Author: lihato Date: Wed Aug 5 11:55:00 2026 +0800 Publish privacy-safe Headscale deployment examples diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6124993 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..d991627 --- /dev/null +++ b/README.md @@ -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. diff --git a/docker-compose.yml.example b/docker-compose.yml.example new file mode 100644 index 0000000..c009d8b --- /dev/null +++ b/docker-compose.yml.example @@ -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 diff --git a/headplane/config/config.yaml.example b/headplane/config/config.yaml.example new file mode 100644 index 0000000..ca3c1ac --- /dev/null +++ b/headplane/config/config.yaml.example @@ -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 diff --git a/headscale/config/acl.hujson.example b/headscale/config/acl.hujson.example new file mode 100644 index 0000000..b5743ff --- /dev/null +++ b/headscale/config/acl.hujson.example @@ -0,0 +1,7 @@ +{ + // Permissive starter policy. Replace it with least-privilege rules. + "acls": [ + { "action": "accept", "src": ["*"], "dst": ["*:*"] } + ], + "ssh": [] +} diff --git a/headscale/config/config.yaml.example b/headscale/config/config.yaml.example new file mode 100644 index 0000000..aab867a --- /dev/null +++ b/headscale/config/config.yaml.example @@ -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 diff --git a/traefik/dynamic/headscale.yml.example b/traefik/dynamic/headscale.yml.example new file mode 100644 index 0000000..d59cbf2 --- /dev/null +++ b/traefik/dynamic/headscale.yml.example @@ -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 diff --git a/traefik/traefik.yml.example b/traefik/traefik.yml.example new file mode 100644 index 0000000..cd884c4 --- /dev/null +++ b/traefik/traefik.yml.example @@ -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