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. |
||
|---|---|---|
| forgejo | ||
| headscale | ||
| ntfy | ||
| syncthing | ||
| .gitignore | ||
| Caddyfile | ||
| compose.yaml | ||
| README.md | ||
central-proxy
The shared edge reverse proxy for the DigitalOcean droplet (174.138.36.232).
A single Caddy container terminates TLS for every site on the box and routes
each hostname to the right upstream container. It is the only thing published on
ports 80 and 443; all the application stacks (bigode, nazareonline, ...) sit
behind it on the external proxy_net Docker network and speak plain HTTP to it.
Caddy manages Let's Encrypt certificates automatically - it obtains and renews a
cert for each hostname listed in the Caddyfile on first request. Issued certs
and ACME account keys persist in the caddy_data Docker volume (mounted at
/data), not in this repo.
Layout
compose.yaml the Caddy service: publishes 80/443, joins proxy_net,
bind-mounts the Caddyfile, persists certs in caddy_data
Caddyfile one site block per app; each reverse_proxies to a container
reachable by name on proxy_net
syncthing/ the Syncthing stack (own compose file); GUI behind Caddy,
sync protocol published on 22000
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
- The external network exists:
docker network create proxy_net. - Each upstream stack attaches its public-facing container to
proxy_net, so Caddy can reach it by container name (e.g.nazareonline-web-1:80). - DNS A records for every hostname in the
Caddyfilepoint at the droplet.
Adding a site
Add a block to the Caddyfile naming the hostname(s) and the upstream:
www.example.com, example.com {
reverse_proxy example-web-1:80
}
The upstream container must be on proxy_net. Caddy forwards the original Host
and sets X-Forwarded-Proto: https, so upstreams behind it can rebuild HTTPS
URLs and do their own host canonicalisation.
Then reload without downtime (does not disturb the other sites):
docker exec central-proxy-caddy-1 caddy reload --config /etc/caddy/Caddyfile
Validate a change before reloading:
docker run --rm -v "$PWD/Caddyfile":/etc/caddy/Caddyfile:ro caddy:2-alpine \
caddy validate --config /etc/caddy/Caddyfile
Deploy
The compose stack lives at /home/waldson/central-proxy on the droplet. To
apply a change, copy the edited Caddyfile up and reload:
scp -i ~/.ssh/deploy_key ./Caddyfile waldson@174.138.36.232:/home/waldson/central-proxy/Caddyfile
ssh -i ~/.ssh/deploy_key waldson@174.138.36.232 \
'docker exec central-proxy-caddy-1 caddy reload --config /etc/caddy/Caddyfile'
Bring the proxy up (or recreate it after a compose change) from that directory:
docker compose up -d
Syncthing stack
An always-on Syncthing peer for the owner's folders, giving devices somewhere
to sync when no two of them are online together and keeping an off-site copy on
the droplet. It lives in syncthing/ and deploys to
/home/waldson/central-proxy/syncthing on the droplet.
Only the sync protocol is published on the host (22000/tcp and 22000/udp),
so devices connect directly instead of through community relays. The web GUI
(8384) is not published; it is reached only through Caddy at
sync.waldson.com.br, guarded by Syncthing's own username/password. The
central Caddyfile already carries the sync.waldson.com.br block. Binding the
GUI to a non-loopback address (STGUIADDRESS=0.0.0.0:8384, required so Caddy in
its own container can reach it) also turns off Syncthing's DNS-rebinding host
check, so a plain reverse_proxy needs no Host-header rewrite.
Where data lives
Config (device keys, the folder list) and every synced folder live under the
host bind mount syncthing/data (mounted at /var/syncthing in the container),
so state survives container recreation and is inspectable over plain SSH. The
directory is git-ignored. PUID/PGID default to 1000; if the droplet's
deploy user has a different id, adjust them in compose.yaml so the synced
files stay owned by that user.
Deploy
From syncthing/ on the droplet:
docker compose up -d
The container comes up as syncthing-syncthing-1 on proxy_net, which is the
name Caddy reverse-proxies to. A restart preserves config and pairings:
docker compose restart
Upgrade
The image is syncthing/syncthing:latest (Syncthing publishes no rolling major
tag; pin a specific version here if you want reproducible upgrades). To update:
docker compose pull && docker compose up -d
First-run setup (interactive, in the GUI)
These steps are done once by the owner at https://sync.waldson.com.br:
- Set the GUI username and password (Actions -> Settings -> GUI).
- Add the droplet as a device on each personal device (and vice versa), using the device IDs shown under Actions -> Show ID.
- Share all five folders (Sync, phone-photos, comprovantes, notes,
torque-data) with the droplet and accept them on the droplet, pointing each
folder path under
/var/syncthingso the data lands in the bind mount.
Forgejo stack
A single-user Forgejo instance (the owner's self-hosted git server) at
git.waldson.com.br. It lives in forgejo/ and deploys to
/home/waldson/central-proxy/forgejo on the droplet.
Only git-over-SSH is published on the host (2222/tcp), so the host's admin
sshd keeps port 22. The web UI (3000) is not published; it is reached only
through Caddy at git.waldson.com.br. The central Caddyfile carries the
git.waldson.com.br block. SQLite is the database, and the whole instance is
configured from environment variables in compose.yaml (registration disabled,
Forgejo Actions disabled, correct clone-URL host/port), so the security posture
is reproduced on every recreate rather than depending on installer clicks.
Where data lives
Everything - git repositories, the SQLite database (/data/gitea/gitea.db), the
generated app.ini, and the container's SSH host keys - lives in the
forgejo_data named Docker volume mounted at /data. That one volume is the
entire instance, so docker compose up -d --force-recreate preserves all users
and repositories. Never docker volume rm forgejo_data: it discards every repo
and account. (Off-site backup of this volume is a separate slice: a nightly
forgejo dump into a Syncthing-shared folder.)
Deploy
From forgejo/ on the droplet:
docker compose up -d
The container comes up as forgejo-forgejo-1 on proxy_net, which is the name
Caddy reverse-proxies to. Recreating it preserves users and repositories:
docker compose up -d --force-recreate
First-run setup (interactive)
The web installer is locked (INSTALL_LOCK=true), so the single admin account
is created from the CLI once, after the container is first up:
docker exec -u git forgejo-forgejo-1 \
forgejo admin user create \
--admin --username <admin> --email <you@example.com> \
--random-password --must-change-password
Log in at https://git.waldson.com.br, change the password, and add your SSH
public key under Settings -> SSH / GPG Keys. Registration is already disabled, so
there is no sign-up path for anyone else.
Cloning over SSH (port 2222)
Forgejo advertises ssh://git@git.waldson.com.br:2222/<user>/<repo>.git. To keep
remotes short (git@git.waldson.com.br:<user>/<repo>.git) on a personal machine,
add this to ~/.ssh/config:
Host git.waldson.com.br
HostName git.waldson.com.br
Port 2222
User git
IdentityFile ~/.ssh/id_ed25519
With that entry, git clone git@git.waldson.com.br:<user>/<repo>.git works
without spelling out the port. HTTPS clone/pull works through Caddy with no SSH
setup at all: git clone https://git.waldson.com.br/<user>/<repo>.git.
Upgrade
The image is pinned to a Forgejo major version (codeberg.org/forgejo/forgejo:11).
Patch and minor updates within that major are safe:
docker compose pull && docker compose up -d
A major bump (editing the tag to the next number) runs one-way database
migrations. Do it one major at a time, back up forgejo_data first, and read the
Forgejo release notes before changing the tag.
ntfy stack
A self-hosted ntfy push-notification server (the owner's
alerts endpoint for scripts, cron, and the Android app) at
push.waldson.com.br. It lives in ntfy/ and deploys to
/home/waldson/central-proxy/ntfy on the droplet.
Nothing is published on the host: ntfy is pure HTTP and reached only through
Caddy at push.waldson.com.br, so it rides Caddy's existing 80/443 and opens no
new firewall port. Caddy transparently upgrades the long-lived WebSocket
connections the Android app and web UI use for instant delivery. The central
Caddyfile carries the push.waldson.com.br block.
Access is deny-all: NTFY_AUTH_DEFAULT_ACCESS=deny-all means no topic can be
published or subscribed without an authenticated user or token, so there is no
open surface for strangers even though the URL is public. The whole instance is
configured from NTFY_* environment variables in compose.yaml (no
server.yml), so the posture is reproduced on every recreate. The container runs
as UID 1000, which cannot bind port 80, so ntfy listens on :8080 inside the
container and Caddy reverse-proxies to ntfy-ntfy-1:8080 - the same high-port
shape as Syncthing (:8384) and Forgejo (:3000).
Where data lives
The auth database (user.db), the message cache (cache.db), and uploaded
attachments all live under the host bind mount ntfy/data (mounted at
/var/lib/ntfy in the container), so state 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 db files stay owned by that
user.
Deploy
From ntfy/ on the droplet:
docker compose up -d
The container comes up as ntfy-ntfy-1 on proxy_net, which is the name Caddy
reverse-proxies to. A restart preserves users, tokens, and cached messages:
docker compose restart
First-run setup (CLI)
There is no web installer and no sign-up path. After the container is first up, create the admin user and (for scripts) a publish token via the CLI:
# Admin can read/write every topic (prompts for a password).
docker exec -it ntfy-ntfy-1 ntfy user add --role=admin <admin>
# A token for unattended publishing from scripts/cron.
docker exec -it ntfy-ntfy-1 ntfy token add <admin>
For a non-admin user, grant per-topic access explicitly (deny-all denies everything else):
docker exec -it ntfy-ntfy-1 ntfy user add <name>
docker exec -it ntfy-ntfy-1 ntfy access <name> <topic> rw
Publish with the token from anywhere:
curl -H "Authorization: Bearer <token>" -d "backup finished" \
https://push.waldson.com.br/<topic>
In the Android app, add the server https://push.waldson.com.br, sign in with
the user (or set the token), and subscribe to the topic.
Upgrade
The image is pinned to the current ntfy major (binwiederhier/ntfy:v2). Patch
and minor updates within that major are safe:
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 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:
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:
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:
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:
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:
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:
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:
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,
2 GB RAM, 2 vCPUs, 58 GB disk). Everything below was set up once and only
needs revisiting when the host changes.
Swap
The droplet has 2 GB RAM and no swap by default; a 2 GB swapfile prevents Out Of Memory (OOM) kills during on-droplet builds and daemon memory spikes. It is created once with (requires sudo):
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
echo 'vm.swappiness=10' | sudo tee /etc/sysctl.d/99-swappiness.conf
sudo sysctl -p /etc/sysctl.d/99-swappiness.conf
The fstab entry makes it survive reboots; swappiness 10 keeps the kernel from
swapping eagerly. Verify with free -m (~2048 total swap) and, after a
reboot, swapon --show.
Firewall
ufw is installed and active on the host. Probing from outside the droplet
(2026-07-23) showed no filtering in the path for the service ports: 2222/tcp
and 22000/tcp answered "connection refused" while nothing listened, which
means packets reach the host, so neither ufw nor a DigitalOcean cloud
firewall blocks them. Ports in use:
| Port | Proto | Service |
|---|---|---|
| 22 | tcp | host sshd |
| 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. 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:
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; only a timeout indicates a filter.
DNS
All records live on the DigitalOcean nameservers (ns1-3.digitalocean.com) and point at the droplet:
| Record | Type | Value | Serves |
|---|---|---|---|
| waldson.com.br | A | 174.138.36.232 | personal site |
| www.waldson.com.br | A | 174.138.36.232 | redirect to apex |
| 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. ts.waldson.com.br (the MagicDNS base domain) deliberately has
no public record: it exists only inside the tailnet. Check resolution with:
dig +short waldson.com.br www.waldson.com.br git.waldson.com.br sync.waldson.com.br remote.waldson.com.br
Notes
letsencrypt/acme.jsonis leftover state from an earlier Traefik-based proxy and is not used by Caddy. It is git-ignored (it contains private keys); it can be deleted from the droplet once you are sure nothing references it.- Never
docker volume rm caddy_data- that discards every issued certificate and forces re-issuance, which can hit Let's Encrypt rate limits.