[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

125
README.md
View file

@ -24,6 +24,8 @@ forgejo/ the Forgejo stack (own compose file); web UI behind Caddy,
git-over-SSH published on 2222
ntfy/ the ntfy stack (own compose file); push server behind Caddy,
nothing published (pure HTTP through the proxy)
headscale/ the Headscale stack (own compose file); control plane behind
Caddy, STUN for the embedded DERP relay published on 3478/udp
```
## Prerequisites
@ -312,6 +314,113 @@ docker compose pull && docker compose up -d
A major bump is a deliberate tag edit; read the ntfy release notes first.
## Headscale stack
A self-hosted [Headscale](https://headscale.net) coordination server (the
open-source Tailscale control plane) for the owner's mesh VPN (Virtual Private
Network) at `remote.waldson.com.br`. It lives in `headscale/` and deploys to
`/home/waldson/central-proxy/headscale` on the droplet. Every device (mercurio,
hermes, quicksilver, the phone, the Home Assistant box) joins as a stock
Tailscale client pointed at this login URL, so remote access to the home LAN
never depends on Tailscale Inc.'s servers.
The control plane and login endpoints ride Caddy's existing 80/443: the central
`Caddyfile` carries the `remote.waldson.com.br` block reverse-proxying to
`headscale-headscale-1:8080`, and Caddy transparently upgrades the long-lived
client control connections (the same handling ntfy relies on). The one thing
published on the host is **STUN on `3478/udp`** for the embedded DERP
(Designated Encrypted Relay for Packets) server; the relay itself rides Caddy's
443. The whole posture (server URL, MagicDNS, embedded DERP) is a committed
`headscale/config.yaml`, so it is reproduced on every recreate rather than
depending on runtime state.
The embedded DERP relay replaces Tailscale Inc.'s DERP map entirely
(`derp.urls: []`), so even traffic that cannot go peer-to-peer is relayed
through this droplet. MagicDNS is enabled with base domain `ts.waldson.com.br`
(deliberately different from the login hostname, which Headscale requires, and
never published to public DNS), so devices reach each other by name
(`ssh mercurio`). The gRPC remote-CLI port is not exposed; administration is
CLI-only via `docker exec ... headscale`, like every other stack.
### Where data lives
The SQLite database (`db.sqlite`), the auto-generated Noise and DERP private
keys, and the CLI unix socket all live under the host bind mount `headscale/data`
(mounted at `/var/lib/headscale` in the container), so state - the node list and
keys - survives container recreation and is inspectable over plain SSH. The
directory is git-ignored via the repo's `data/` rule. The container runs as
`1000:1000`; if the droplet's deploy user has a different id, adjust `user:` in
`compose.yaml` so the state files stay owned by that user. Headscale's default
unix socket lives under root-only `/var/run`, so `config.yaml` relocates it into
this data dir to keep the non-root container's CLI working.
### Deploy
From `headscale/` on the droplet:
```sh
docker compose up -d
```
The container comes up as `headscale-headscale-1` on `proxy_net`, which is the
name Caddy reverse-proxies to. Recreating it preserves the node list and keys:
```sh
docker compose up -d --force-recreate
```
### First-run setup (CLI)
There is no web sign-up. All devices register under a single Headscale user,
created once via the CLI after the container is first up:
```sh
docker exec headscale-headscale-1 headscale users create waldson
docker exec headscale-headscale-1 headscale users list
```
### Enrolling a device
Registration is manual one-time approval per device (no pre-shared keys). On the
client, point Tailscale at this login server:
```sh
tailscale up --login-server https://remote.waldson.com.br
```
The client prints a registration URL ending in an auth id; approve the node
under the user on the droplet with that id:
```sh
docker exec headscale-headscale-1 \
headscale auth register --user waldson --auth-id <auth-id-from-client-url>
docker exec headscale-headscale-1 headscale nodes list
```
Subnet routes advertised by a node (e.g. the Home Assistant box and mercurio
advertising the home LAN) are approved per node:
```sh
docker exec headscale-headscale-1 headscale nodes approve-routes --identifier <id> --routes <cidr>
```
### Upgrade
The image is pinned to the current Headscale minor (`headscale/headscale:v0.29`).
Because Headscale is pre-1.0, the minor is the breaking-change axis; patch
updates within it are safe:
```sh
docker compose pull && docker compose up -d
```
A minor bump (editing the tag to `v0.30`) can change the config schema and may
run database migrations: diff `config.yaml` against that release's
`config-example.yaml`, back up `headscale/data` first, and read the release
notes. Headscale also trails the newest Tailscale client capabilities, so review
client compatibility when bumping - if a client refuses to connect after an
upgrade, version skew is the first suspect.
## Droplet groundwork
Baseline host configuration for the droplet (`wloud`, 174.138.36.232,
@ -352,15 +461,19 @@ firewall blocks them. Ports in use:
| 80, 443 | tcp | Caddy (all HTTP/HTTPS traffic) |
| 2222 | tcp | Forgejo git-over-SSH |
| 22000 | tcp+udp | Syncthing sync protocol |
| 3478 | udp | Headscale STUN (embedded DERP) |
ntfy adds no row here: it is reached only through Caddy on 80/443. If ufw
rules are ever tightened, keep the four rows above allowed. To
re-verify reachability from any machine outside the droplet:
ntfy adds no row here: it is reached only through Caddy on 80/443. Headscale is
also fronted by Caddy on 80/443, but its embedded DERP relay needs STUN on
3478/udp for NAT traversal, which is the one row it adds. If ufw rules are ever
tightened, keep the rows above allowed. To re-verify reachability from any
machine outside the droplet:
```sh
nc -vz 174.138.36.232 2222
nc -vz 174.138.36.232 22000
nc -vzu 174.138.36.232 22000
nc -vzu 174.138.36.232 3478
```
"Connection refused" means the port is reachable but nothing listens yet;
@ -378,12 +491,14 @@ and point at the droplet:
| git.waldson.com.br | A | 174.138.36.232 | Forgejo |
| sync.waldson.com.br| A | 174.138.36.232 | Syncthing GUI |
| push.waldson.com.br| A | 174.138.36.232 | ntfy push server |
| remote.waldson.com.br | A | 174.138.36.232 | Headscale control plane |
personaledson.com.br and nazareonline.com.br also resolve here for the
existing stacks. Check resolution with:
existing stacks. `ts.waldson.com.br` (the MagicDNS base domain) deliberately has
**no** public record: it exists only inside the tailnet. Check resolution with:
```sh
dig +short waldson.com.br www.waldson.com.br git.waldson.com.br sync.waldson.com.br
dig +short waldson.com.br www.waldson.com.br git.waldson.com.br sync.waldson.com.br remote.waldson.com.br
```
## Notes