# 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 `Caddyfile` point 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): ```sh docker exec central-proxy-caddy-1 caddy reload --config /etc/caddy/Caddyfile ``` Validate a change before reloading: ```sh 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: ```sh 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: ```sh 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: ```sh 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: ```sh 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: ```sh 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`: 1. Set the GUI username and password (Actions -> Settings -> GUI). 2. Add the droplet as a device on each personal device (and vice versa), using the device IDs shown under Actions -> Show ID. 3. 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/syncthing` so 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: ```sh 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: ```sh 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: ```sh docker exec -u git forgejo-forgejo-1 \ forgejo admin user create \ --admin --username --email \ --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//.git`. To keep remotes short (`git@git.waldson.com.br:/.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:/.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//.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: ```sh 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](https://ntfy.sh) 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: ```sh 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: ```sh 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: ```sh # Admin can read/write every topic (prompts for a password). docker exec -it ntfy-ntfy-1 ntfy user add --role=admin # A token for unattended publishing from scripts/cron. docker exec -it ntfy-ntfy-1 ntfy token add ``` For a non-admin user, grant per-topic access explicitly (deny-all denies everything else): ```sh docker exec -it ntfy-ntfy-1 ntfy user add docker exec -it ntfy-ntfy-1 ntfy access rw ``` Publish with the token from anywhere: ```sh curl -H "Authorization: Bearer " -d "backup finished" \ https://push.waldson.com.br/ ``` 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: ```sh 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 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 --routes ``` ### 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, 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): ```sh 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: ```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; 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: ```sh dig +short waldson.com.br www.waldson.com.br git.waldson.com.br sync.waldson.com.br remote.waldson.com.br ``` ## Notes - `letsencrypt/acme.json` is 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.