The home for my hosted personal solutions (syncthing, git, ntfy.sh)
Find a file
2026-07-23 14:49:24 -03:00
forgejo [003] Add Forgejo stack behind Caddy at git.waldson.com.br 2026-07-23 13:48:44 -03:00
ntfy [004] Add ntfy stack behind Caddy at push.waldson.com.br 2026-07-23 14:49:24 -03:00
syncthing [002] Add Syncthing stack behind Caddy at sync.waldson.com.br 2026-07-23 13:34:10 -03:00
.gitignore Version-control the shared Caddy edge proxy 2026-07-23 12:13:20 -03:00
Caddyfile [004] Add ntfy stack behind Caddy at push.waldson.com.br 2026-07-23 14:49:24 -03:00
compose.yaml Version-control the shared Caddy edge proxy 2026-07-23 12:13:20 -03:00
README.md [004] Add ntfy stack behind Caddy at push.waldson.com.br 2026-07-23 14:49:24 -03:00

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)

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):

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:

  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:

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.

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

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:

nc -vz 174.138.36.232 2222
nc -vz 174.138.36.232 22000
nc -vzu 174.138.36.232 22000

"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

personaledson.com.br and nazareonline.com.br also resolve here for the existing stacks. Check resolution with:

dig +short waldson.com.br www.waldson.com.br git.waldson.com.br sync.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.