[009] Add Headscale stack behind Caddy at remote.waldson.com.br

Headscale (the self-hosted Tailscale control plane) runs as a new stack on
proxy_net, following the house pattern of syncthing/forgejo/ntfy: own
directory, own compose file, committed config, git-ignored bind-mounted state,
CLI-only admin via docker exec.

- headscale/config.yaml: server_url https://remote.waldson.com.br, MagicDNS
  with base domain ts.waldson.com.br, embedded DERP relay with the upstream
  Tailscale DERP map dropped so relayed traffic stays on the droplet, HA
  subnet-router route failover, SQLite state under /var/lib/headscale. The CLI
  unix socket is relocated into the data dir so the UID 1000 container can use
  it.
- headscale/compose.yaml: image pinned to the v0.29 minor, runs as 1000:1000,
  publishes only STUN on 3478/udp; gRPC not exposed.
- Caddyfile: remote.waldson.com.br reverse-proxies to headscale-headscale-1:8080.
- README: Headscale stack section (deploy, upgrade, user/node CLI runbook),
  firewall table row for 3478/udp, DNS row for remote.waldson.com.br.
This commit is contained in:
Waldson Patrício 2026-07-23 15:42:04 -03:00
parent 04df6b9c09
commit 6da5b3a8ab
4 changed files with 320 additions and 5 deletions

50
headscale/compose.yaml Normal file
View file

@ -0,0 +1,50 @@
name: headscale
# Self-hosted Headscale coordination server (the open-source Tailscale control
# plane) for the owner's tailnet, behind the central Caddy proxy at
# remote.waldson.com.br. The HTTP API and the login/OIDC endpoints are reached
# only through Caddy on proxy_net; Caddy transparently upgrades the long-lived
# client control connections, the same as ntfy. The one thing published to the
# host is STUN on 3478/udp for the embedded DERP relay. Administration is
# CLI-only via `docker exec ... headscale` (see README.md for the deploy,
# upgrade, and user/node runbook). The whole posture lives in the committed
# config.yaml; only SQLite state and generated keys are mutable at runtime.
services:
headscale:
# Pinned to the current minor series. Headscale is pre-1.0, so the minor is
# the breaking-change axis: `pull` brings 0.29.x patches while a minor bump
# (0.30) stays a deliberate tag edit. Headscale trails new Tailscale client
# capabilities, so review compatibility before bumping the tag - if a client
# refuses to connect after an upgrade, version skew is the first suspect.
image: headscale/headscale:v0.29
# The image entrypoint is `headscale`; the server needs the `serve`
# subcommand (same shape as ntfy's `serve`).
command: serve
restart: unless-stopped
# Run as the droplet's deploy user so the SQLite db and generated private
# keys in the bind mount stay owned by 1000 and are manageable over plain
# SSH, matching the Syncthing and ntfy stacks. Headscale's default unix
# socket lives under root-only /var/run; config.yaml relocates it into the
# data dir so this non-root process can create it and the CLI still works.
user: "1000:1000"
volumes:
# Committed posture: server URL, MagicDNS, embedded DERP. Read-only.
- ./config.yaml:/etc/headscale/config.yaml:ro
# SQLite database, noise/DERP private keys, and the CLI socket live here on
# a host bind mount, so the instance survives recreation and is
# inspectable over SSH. Git-ignored via the repo's data/ rule.
- ./data:/var/lib/headscale
ports:
# STUN for the embedded DERP relay's NAT traversal. The relay itself and
# every control connection ride Caddy's existing 443; only this UDP port
# is new on the host firewall. The gRPC remote-CLI port is deliberately
# not published - admin is via docker exec over the unix socket.
- "3478:3478/udp"
mem_limit: 256m
networks:
- proxy_net
networks:
proxy_net:
external: true

146
headscale/config.yaml Normal file
View file

@ -0,0 +1,146 @@
# Headscale coordination server for the owner's self-hosted tailnet, reached at
# https://remote.waldson.com.br through the central Caddy proxy. This file is
# the committed posture: MagicDNS, the embedded DERP relay, and the SQLite data
# layout are all reproduced on every recreate instead of depending on runtime
# state. Only the SQLite database and the auto-generated private keys (under
# /var/lib/headscale, the git-ignored bind mount) are mutable at runtime.
#
# Schema tracks the pinned image (headscale/headscale:v0.29). When bumping the
# minor tag, diff this against that release's config-example.yaml: Headscale's
# config schema changes between minors.
# Public login URL every client connects to. Caddy terminates TLS for this
# hostname and reverse-proxies to listen_addr below. DERP requires https here.
server_url: https://remote.waldson.com.br
# Bind on all interfaces inside the container so Caddy can reach it by name on
# proxy_net (headscale-headscale-1:8080). The port is never published to the
# host; only Caddy on 80/443 fronts it, the same shape as the other stacks.
listen_addr: 0.0.0.0:8080
# Metrics and gRPC stay bound to loopback inside the container and are never
# published. Administration is done with `docker exec ... headscale` over the
# unix socket below, so the gRPC remote-CLI surface is not exposed.
metrics_listen_addr: 127.0.0.1:9090
grpc_listen_addr: 127.0.0.1:50443
grpc_allow_insecure: false
# Caddy is the only thing in front of Headscale; trust its forwarding headers so
# logs and rate limiting see the real client IP. Caddy reaches Headscale from
# its own container on proxy_net, so trust that private range.
trusted_proxies:
- 172.16.0.0/12
noise:
private_key_path: /var/lib/headscale/noise_private.key
# Tailnet address pools. Left at the standard Tailscale CGNAT/ULA ranges.
prefixes:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48
allocation: sequential
# Embedded DERP relay: run our own relay and STUN so even traffic that cannot go
# direct is relayed through this droplet, never through Tailscale Inc. The
# upstream DERP map is dropped (urls: [] and no auto-update) so the tailnet uses
# only this region.
derp:
server:
enabled: true
region_id: 999
region_code: "waldson"
region_name: "Waldson Embedded DERP"
verify_clients: true
# STUN for NAT traversal. Published on the host as 3478/udp (the one new
# firewall row this stack adds); mandatory when the embedded DERP is on.
stun_listen_addr: "0.0.0.0:3478"
private_key_path: /var/lib/headscale/derp_server_private.key
automatically_add_embedded_derp_region: true
# Droplet public IPv4 so clients can reach this DERP region directly for
# better connection stability. No IPv6 line: the droplet has no stable
# public IPv6, and advertising a bogus one would send clients to a dead
# address.
ipv4: 174.138.36.232
# No external DERP maps: relayed traffic must stay on our own infrastructure.
urls: []
paths: []
auto_update_enabled: false
update_frequency: 3h
disable_check_updates: false
node:
expiry: 0
ephemeral:
inactivity_timeout: 30m
# HA subnet-router health probing. Kept on so that when the Home Assistant
# box (primary) and mercurio (failover) both advertise 192.168.0.0/24, the
# primary role fails over to whichever is healthy. This is the route-failover
# support the tailnet topology depends on.
routes:
ha:
probe_interval: 10s
probe_timeout: 5s
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 Caddy, so Headscale serves plain HTTP on listen_addr and
# manages no certificates of its own.
tls_letsencrypt_hostname: ""
tls_letsencrypt_challenge_type: HTTP-01
tls_cert_path: ""
tls_key_path: ""
log:
level: info
format: text
policy:
# Allow-all within the tailnet: single-user fleet, the tailnet boundary is the
# security boundary. An empty path keeps the built-in allow-all default.
mode: file
path: ""
# MagicDNS so devices reach each other by name (ssh mercurio). The base domain
# is deliberately different from server_url's host, which Headscale requires,
# and exists only inside the tailnet (no public records for it).
dns:
magic_dns: true
base_domain: ts.waldson.com.br
override_local_dns: true
nameservers:
global:
- 1.1.1.1
- 1.0.0.1
- 2606:4700:4700::1111
- 2606:4700:4700::1001
split: {}
search_domains: []
extra_records: []
# CLI connects over this unix socket with no auth. Relocated from the default
# /var/run/headscale (root-owned, not writable by our non-root container) into
# the bind-mounted data dir, so the UID 1000 process can create it and
# `docker exec ... headscale` works.
unix_socket: /var/lib/headscale/headscale.sock
unix_socket_permission: "0770"
logtail:
enabled: false
taildrop:
enabled: true
auto_update:
enabled: false