# arbret on prd-vipy: monitoring and backups Handoff for the arbret owner. It covers what changed when prd-arbret was lost, and what your Ansible repo needs to do so that arbret is **monitored by Gatus** and **backed up to small-backups-box** from its new home. Written 2026-10-17, during the rebuild. --- ## 0. What changed, in one screen | | Before | Now | |---|---|---| | Host | `prd-arbret`, a droplet of its own | **`prd-vipy`**, shared with the personal_infra services | | Monitoring | Uptime Kuma (push URLs on `uptime.contrapeso.xyz`) | **Gatus** at `https://status.contrapeso.xyz`, running on `prd-spacey`. Uptime Kuma is gone for good. | | Backup destination | small-backups-box pulls `arbret@prd-arbret:/opt/arbret/backups/` | small-backups-box pulls **`backup-pull@prd-vipy:/opt/backups/arbret/`** | | TLS / front door | your own, on prd-arbret | **our Caddy on prd-vipy** (it owns 80/443) | Who does what: | Area | **You** (arbret repo) | **Us** (personal_infra repo) | |---|---|---| | App, workers, Postgres, analytics | deploy and run them | — | | Public HTTPS for `arbret.com` | tell us your local port | Caddy vhost, certificates | | Gatus endpoint definitions | — | all of them: Gatus config lives on prd-spacey, which you don't touch | | Pulled checks (HTTPS, `/api/health`) | keep `/api/health` honest | define and run them | | systemd unit checks (`arbret-server`, `arbret-worker`, `postgresql`) | keep the unit names stable | add them to vipy's monitored units | | Host liveness and disk | — | already covered for prd-vipy | | Analytics timer result | **push it** (§2.4) | register the endpoint | | Backup dump | **produce it to the contract** (§3) | pull it, retain it 90 days, check freshness | | Backup dump result | **push it** (§3.5) | register the endpoint | Your work comes down to three things: 1. Live on a shared host without stepping on it (§1). 2. Push two results to Gatus: analytics and backup. 3. Write the dump to a new place, in a fixed shape. --- ## 1. Living on a shared host prd-vipy also runs Vaultwarden, Forgejo, LNbits, phoenixd and a reverse proxy for a dozen other services. Our Ansible converges it regularly. Anything below that your playbooks change will be reverted by ours, or will break ours. ### Owned by personal_infra: do not manage these - **Ports 80 and 443 and the Caddy install** (`/etc/caddy/**`). Don't install nginx, certbot or a second Caddy, and don't bind 80/443. Give us a port and we write the vhost (see below). - **UFW and fail2ban.** The default policy is deny-incoming, with only SSH and 80/443 open. Don't disable UFW or reset its rules. If you need a port reachable from outside, ask us. - **Users `counterweight` and `backup-pull`**, and their `authorized_keys`. In particular **don't create, modify or authorise keys for `backup-pull`**: we create it and it is shared by every backup on this host (§3). - **`/opt/backups/`** (the parent directory, `root:root 0755`). You own exactly `/opt/backups/arbret/` and nothing beside it. - **Tailscale/Headscale membership** of the host, and the tailnet ACLs. - **Docker.** It's installed for our services. You don't need it (arbret is native systemd); if you start using it, don't touch `/etc/docker/daemon.json`. - systemd unit names we use: `caddy`, `forgejo`, `lnbits`, `phoenixd`, `*-backup.{service,timer}` for vaultwarden/lnbits/forgejo, `liveness-healthcheck`, `disk-usage-healthcheck`, `service-health-healthcheck`. Prefix all of yours with `arbret-` and there's no risk of collision. ### Free for you - PostgreSQL. We run no Postgres on this host, so the system `postgresql` package and cluster are yours. - `/opt/arbret/`, `/etc/arbret/`, the `arbret` system user, `arbret-*` units and timers. ### Local ports already taken `22, 80, 443, 3000, 7657, 8222, 8333, 8765, 9740, 23334, 50002`. Bind your app to `127.0.0.1:` on anything else and tell us the port. Our Caddy then serves `arbret.com` with a Let's Encrypt certificate and reverse-proxies to it, setting `X-Forwarded-For`, `X-Forwarded-Proto` and `X-Forwarded-Host`. Configure the app to trust proxy headers from `127.0.0.1` only. Port `4117` used to be reachable from one admin laptop over the tailnet. If that's still needed, say so and we'll add the ACL for prd-vipy. Don't open it in UFW yourself. ### The host itself Debian 13, clock in **UTC**, tailnet name `prd-vipy`. --- ## 2. Monitoring with Gatus ### 2.1 How it works here Gatus runs on prd-spacey and answers at `https://status.contrapeso.xyz`. Alerts go to Signal. There are two kinds of check: - **Pulled.** Gatus makes the request itself (HTTP, TCP, DNS) every N seconds and evaluates conditions. Nothing is installed on your side. This is the right kind for anything with a reachable URL. - **Pushed ("external endpoints").** Something on the host `POST`s a result to Gatus, and Gatus alarms if a result is `false` **or if nothing arrives within the heartbeat window**. This is the right kind for things with no URL: timers, jobs, internal state. The heartbeat is what makes a dead timer visible: silence is a failure. Everything about an endpoint (name, group, token, heartbeat, alert rules) is defined in **our** repo, because Gatus's config files live on prd-spacey. Your side of a push check is just an HTTP call. ### 2.2 The push API ``` POST https://status.contrapeso.xyz/api/v1/endpoints/{KEY}/external?success={true|false}&error={message} Authorization: Bearer {TOKEN} ``` - **`KEY`** is `sanitize(group) + "_" + sanitize(name)`, where sanitize lowercases and turns any of `/ _ . , space # + &` into `-`. Use the exact keys in the table below. - **`success`**: `true` or `false`. - **`error`**: free text, URL-encoded. It's shown on the dashboard and in the Signal alert, so on success it's fine to send something useful like `ok 31M`. - **`TOKEN`**: a bearer token we give you (§4). It's dedicated to arbret: it can only write arbret's endpoints, and nobody else's token can write yours. Responses: | HTTP | Meaning | |---|---| | `200` | recorded | | `401` | wrong token for that key | | `404` | key not registered (yet). We register before you deploy. | Test it by hand once you have the token: ```bash curl -i -X POST -H "Authorization: Bearer $TOKEN" \ "https://status.contrapeso.xyz/api/v1/endpoints/probe_arbret-analytics/external?success=true&error=manual%20test" ``` ### 2.3 Your old Uptime Kuma monitors, mapped | Old monitor (prod) | New check | Gatus key | Kind | Who | |---|---|---|---|---| | `https`: front door returns 200 | `GET https://arbret.com/` → 200 | `public_arbret` | pulled, 5 min | us | | `health`: `/api/health` returns ok | `GET https://arbret.com/api/health` → 200 and `[BODY].status == ok` | `public_arbret-health` | pulled, 5 min | us | | `arbret-server`: unit active | systemd unit check on prd-vipy | `services_vipy-arbret-server` | push, every 5 min, by our host script | us | | `arbret-worker`: unit active | same | `services_vipy-arbret-worker` | same | us | | `postgresql`: unit active | same | `services_vipy-postgresql` | same | us | | `arbret-analytics`: timer ran OK | your analytics run reports its result | `probe_arbret-analytics` | **push, by you**, hourly; heartbeat 130m | **you** (§2.4) | | (new) beancount: daily job ran OK | your beancount run reports its result | `probe_arbret-beancount` | **push, by you**, daily 03:00; heartbeat 26h | **you** (§2.4, same hook) | | `arbret-backup`: timer ran OK | your backup run reports its result | `backup-dump_arbret` | **push, by you** | **you** (§3.5) | | prd-arbret liveness / disk | covered by prd-vipy's own host checks | `liveness_vipy`, `disk_vipy` | push | us | We also check: - `backup-store_arbret`: the backup **arrived** on small-backups-box, is fresh (<26h, read from the filename timestamp), non-empty, and not suspiciously smaller than the previous one. - `arbret.com` resolves to prd-vipy, checked every 6 hours. - `arbret.com` registration expiry, alarming 14 days ahead. This already existed. **Delete, don't port, every Uptime Kuma push script, timer and URL in your repo.** The endpoint no longer exists. A leftover timer will just fail silently every two minutes forever. The pulled health check relies on `/api/health` returning HTTP 200 with a JSON body that has `"status": "ok"` when healthy, and **a non-200 when it isn't**. If the body differs, tell us the exact shape. A health endpoint that checks the database is worth more than one that only proves the process answers. ### 2.4 What you implement: the analytics push The cleanest hook is `ExecStopPost=` on the oneshot service the timer runs. systemd runs it after every run, success or failure, and sets `$SERVICE_RESULT` and `$EXIT_STATUS` for it. So no wrapper script is needed, and the run itself stays untouched. `/etc/arbret/gatus.env` (`root:root 0600`): ``` GATUS_PUSH_BASE=https://status.contrapeso.xyz/api/v1/endpoints GATUS_PUSH_TOKEN= ``` `/usr/local/bin/arbret-gatus-push` (`root:root 0755`): ```bash #!/usr/bin/env bash # Usage: arbret-gatus-push # Meant for ExecStopPost=, where systemd provides SERVICE_RESULT / EXIT_STATUS. # Can also be called by hand: SERVICE_RESULT=success arbret-gatus-push set -uo pipefail KEY="$1" : "${GATUS_PUSH_BASE:?}" "${GATUS_PUSH_TOKEN:?}" if [ "${SERVICE_RESULT:-}" = "success" ]; then success=true; msg="ok" else success=false; msg="${SERVICE_RESULT:-unknown} exit=${EXIT_STATUS:-?}" fi enc=$(printf '%s' "$msg" | sed 's/%/%25/g; s/ /%20/g; s/&/%26/g; s/+/%2B/g; s/#/%23/g; s/=/%3D/g') # Never let a monitoring hiccup fail the job itself. curl -s -o /dev/null --max-time 15 --retry 2 --retry-delay 3 -X POST \ -H "Authorization: Bearer ${GATUS_PUSH_TOKEN}" \ "${GATUS_PUSH_BASE}/${KEY}/external?success=${success}&error=${enc}" || true exit 0 ``` In the analytics service unit: ```ini [Service] Type=oneshot EnvironmentFile=/etc/arbret/gatus.env ExecStart=...your existing command... ExecStopPost=/usr/local/bin/arbret-gatus-push probe_arbret-analytics ``` Two things to get right: - `EnvironmentFile=` puts the token in the unit's environment, **not** in the unit file, so the unit file can stay world-readable while the env file is `0600`. - **Tell us the timer's schedule.** We set the heartbeat to the interval plus slack: a 5-minute job gets an 11-minute window, a daily job gets 26 hours. If the schedule changes later, tell us, or Gatus will either alarm on a healthy job or sleep through a dead one. As Ansible, this is two templated files, an `EnvironmentFile=` line and an `ExecStopPost=` line. It's small enough to live in the role that deploys the analytics timer, rather than becoming a role of its own. ### 2.5 Staging (arbret-staging-box) arbret-staging-box is a VM in our inventory, so its liveness and disk checks already exist. The same mapping applies with these keys: | Check | Key | Who | |---|---|---| | front door / health | `public_arbret-staging`, `public_arbret-staging-health` | us | | units | `services_arbret-staging-local-arbret-server` (and `-worker`, `-postgresql`) | us | | analytics | `probe_arbret-staging-analytics` | you, with the **staging** token | | backup dump | `backup-dump_arbret-staging` | you, with the staging token. Only if staging should be backed up; tell us. | The staging vhost (`arbretstaging.contrapeso.xyz`) lived on the old prd-vipy and was not in either repo. We're recreating it on our side. --- ## 3. Backups ### 3.1 The pipeline ``` 02:00 UTC prd-vipy your timer: pg_dump | age -r -> /opt/backups/arbret/arbret_.dump.age your script: POST backup-dump_arbret (success/failure) 04:00 UTC small-backups-box rsync backup-pull@prd-vipy:/opt/backups/arbret/ -> ~/backups/arbret/ (keeps 90 days) 05:30 UTC small-backups-box check-backups.sh -> POST backup-store_arbret (then every 6 h) ``` - **The box only pulls.** Nothing on prd-vipy can reach into the backup box. The pull logs in as `backup-pull`, an unprivileged account with a `restrict`ed key that can read the backup directories and nothing else. - **The source encrypts.** Only the age **public** key is on prd-vipy, so the host can't decrypt its own backups. The private key lives offline with the infra owner. Plaintext never touches disk or the network. - **Retention is two-tier.** Keep about 7 days locally (the disk is shared). The box keeps 90. Your existing dump role already produces the right **artefact**: a Postgres custom-format dump, age-encrypted, named `arbret_YYYYmmdd_HHMMSS.dump.age`. The last pre-incident one is `arbret_20261016_020001.dump.age`. Only its **location, ownership and reporting** change. ### 3.2 What changes from prd-arbret | | prd-arbret | prd-vipy | |---|---|---| | Directory | `/opt/arbret/backups/` | **`/opt/backups/arbret/`** | | Account the box pulls as | `arbret` (your user, with our key authorised) | **`backup-pull`** (ours, already exists) | | SSH key for the pull | you authorised it | **nothing to do.** Remove that task, and don't authorise anything for `arbret` or `backup-pull`. | | Result reporting | Uptime Kuma push | Gatus push to `backup-dump_arbret` | ### 3.3 The contract The checks on the box enforce these, and some failures are silent, so read every line. 1. **Directory:** `/opt/backups/arbret/`, owner `root`, group `backup-pull`, mode `0750`. Don't change `/opt/backups/` itself (`root:root 0755`, ours). The `backup-pull` group exists before your playbook runs; if it doesn't, fail loudly rather than creating it. 2. **Filename:** `arbret_YYYYmmdd_HHMMSS.dump.age`, timestamp in the host's local time (UTC). The prefix must be exactly `arbret_`, because the box's prune and freshness checks glob on `arbret_*`, and it reads the **dump time from the filename**. 3. **Encryption:** `age -r ` in the pipe (§4). Nothing unencrypted is ever written to disk. 4. **Atomicity:** write `.partial`, set ownership and mode, then `mv` it to the final name. The pull skips `*.partial`. A half-written file under the final name would be pulled and kept for 90 days as if it were a backup. 5. **File ownership:** `root:backup-pull`, mode `0640`, on both the `.partial` and the final file. If the pull account can't read a file, **rsync fails for the whole directory**, so one bad file blocks every later night. 6. **`set -euo pipefail`.** Without `pipefail`, a failing `pg_dump` piped into a succeeding `age` exits 0 and produces a tiny, valid-looking, useless artefact. 7. **Timing:** finished before 04:00 UTC. 02:00 is good. 8. **Local prune:** delete `arbret_*.dump.age` older than ~7 days, plus stale `arbret_*.partial` files at the start of each run. Put nothing else in the directory: no subdirectories, logs or checksums. 9. **Report** the outcome to Gatus on every exit path (§3.5). 10. **No service stop is needed.** `pg_dump` takes a consistent snapshot online. Don't stop `arbret-server` for it. ### 3.4 Reference script This is the same shape as the script every other service on the host uses (our `backup_source` role), adapted to `pg_dump`. Adjust the database name and the `pg_dump` invocation to match your current role. `/usr/local/bin/arbret-backup.sh` (`root:root 0750`): ```bash #!/usr/bin/env bash set -euo pipefail umask 077 BACKUP_DIR=/opt/backups/arbret RECIPIENT="age192wwdaseqej2ggwyp884gtm05c396anp7chr0vr8m47g50fahpyqr9fsza" RETENTION_DAYS=7 NAME=arbret DB=arbret # <- your database name ARTIFACT="${BACKUP_DIR}/${NAME}_$(date +%Y%m%d_%H%M%S).dump.age" PUSH_URL="${GATUS_PUSH_BASE:-}/backup-dump_arbret/external" PUSH_TOKEN="${GATUS_PUSH_TOKEN:-}" report() { # $1=true|false $2=message [ -n "${GATUS_PUSH_BASE:-}" ] || return 0 local enc; enc=$(printf '%s' "$2" | sed 's/%/%25/g; s/ /%20/g; s/&/%26/g; s/+/%2B/g; s/#/%23/g') curl -s -o /dev/null --max-time 15 --retry 2 --retry-delay 3 -X POST \ -H "Authorization: Bearer ${PUSH_TOKEN}" "${PUSH_URL}?success=$1&error=${enc}" || true } on_exit() { # the ONE exit trap: bash keeps only one, a second replaces it local rc=$? if [ "$rc" -ne 0 ]; then report false "arbret dump exited ${rc}" elif [ ! -s "$ARTIFACT" ]; then report false "arbret produced no artefact" else report true "arbret $(du -h "$ARTIFACT" | cut -f1)"; fi } trap on_exit EXIT command -v age >/dev/null || { echo "age not installed" >&2; exit 1; } getent group backup-pull >/dev/null || { echo "group backup-pull missing - personal_infra has not provisioned this host" >&2; exit 1; } install -d -o root -g backup-pull -m 0750 "$BACKUP_DIR" rm -f "${BACKUP_DIR}/${NAME}_"*.partial runuser -u postgres -- pg_dump -Fc "$DB" | age -r "$RECIPIENT" -o "${ARTIFACT}.partial" chown root:backup-pull "${ARTIFACT}.partial"; chmod 0640 "${ARTIFACT}.partial" mv "${ARTIFACT}.partial" "$ARTIFACT" find "$BACKUP_DIR" -maxdepth 1 -type f -name "${NAME}_*.dump.age" -mtime +"$RETENTION_DAYS" -delete echo "wrote $ARTIFACT" ``` Units: ```ini # /etc/systemd/system/arbret-backup.service (0644; the token is in the env file, not here) [Unit] Description=arbret database backup After=postgresql.service [Service] Type=oneshot EnvironmentFile=/etc/arbret/gatus.env ExecStart=/usr/local/bin/arbret-backup.sh # /etc/systemd/system/arbret-backup.timer [Unit] Description=arbret database backup, nightly [Timer] OnCalendar=*-*-* 02:00:00 Persistent=true [Install] WantedBy=timers.target ``` `age` and `curl` come from Debian (`apt install age curl`). Installing them from your playbook is fine, since both repos asking for the same package doesn't conflict. ### 3.5 Reporting the backup The script above already reports, on every exit path, to `backup-dump_arbret`, using the same `/etc/arbret/gatus.env` as the analytics push. Its heartbeat is 26 hours: one missed night alarms. The box-side check (`backup-store_arbret`) independently notices within 6 hours of the next pull if the artefact stops arriving or stops being fresh. So a broken dump and a broken pull show up as **different** red lights. ### 3.6 Verify after your first deploy ```bash sudo systemctl start arbret-backup.service sudo journalctl -u arbret-backup.service -n 20 --no-pager ls -l /opt/backups/arbret/ # root backup-pull, -rw-r----- sudo -u backup-pull head -c 30 /opt/backups/arbret/arbret_*.dump.age # must print "age-encryption.org/v1" systemctl list-timers arbret-backup.timer ``` Then check that `backup-dump_arbret` is green on the status page, and tell us. We'll trigger a pull rather than wait for 04:00, and confirm `backup-store_arbret`. ### 3.7 Restoring Only the infra owner can decrypt: the age identity is offline. They'll hand you a decrypted dump over a secure channel. The latest pre-incident one is `arbret_20261016_020001.dump.age` (31.9 MB, sha256 `2bb771957513654cb907e5d63153e7714d54c7d5695d7caf09a05f948dcf515d`). Anything written to arbret after 2026-10-16 02:00 UTC is not in any backup. For a custom-format dump: ```bash sudo -u postgres createdb -O arbret arbret # or let --create do it sudo -u postgres pg_restore -d arbret --no-owner --role=arbret -j 4 arbret_20261016_020001.dump ``` Create the roles the app expects first. `pg_dump -Fc` doesn't include them. Then shred the plaintext dump: `shred -u arbret_20261016_020001.dump`. --- ## 4. What you'll receive from us | Item | Secret | Used for | |---|---|---| | Gatus push token, **prod** | yes | `/etc/arbret/gatus.env` on prd-vipy. Keep it in your vault. | | Gatus push token, **staging** | yes | the same file on arbret-staging-box, if you do the staging pushes | | age recipient `age192wwdaseqej2ggwyp884gtm05c396anp7chr0vr8m47g50fahpyqr9fsza` | **no**, it's a public key | the backup script. Safe to commit in plain text. | | SSH access to prd-vipy for your playbooks | access | being arranged separately | | The decrypted 2026-10-16 dump | yes, it's the full prod DB | restore, handed over once | You need **no** SSH key for the backup pull, and nothing from the old Uptime Kuma setup. ## 5. What we need from you 1. The local port arbret listens on (for the `arbret.com` vhost), and whether `www.arbret.com` should be served or redirected. 2. The exact `/api/health` response when healthy, and confirmation that it returns a non-200 when the DB is down. 3. The analytics timer's schedule (sets the heartbeat). 4. Whether port 4117 over the tailnet is still needed. 5. The final unit names, if not `arbret-server`, `arbret-worker`, `postgresql`. 6. Whether staging should be backed up, and whether staging pushes are in scope. 7. Who besides the infra owner should get Signal alerts for arbret. Once we have 1 to 3, we register the endpoints, the vhost and the pull source. Then you can deploy and see green on the first run.