prd-arbret was folded into vipy. arbret itself (app, worker, Postgres, timers, its backup dump) is deployed by its owner's own repo; this repo owns the shared host's side of it. arbret_handoff.md documents the split, the Gatus push API and the backup contract. - services/arbret: arbret.com -> localhost:3100, www redirects to the apex, port 4117 allowed on tailscale0 only, /opt/backups/arbret pre-created to the contract so the pull job is green before the first dump. - headscale ACL: framie may reach prd-vipy:4117. - vipy: arbret-server, arbret-worker and postgresql in the unit checks. - 402: DNS and HTTP checks for arbret.com, www, and /api/health with [BODY].status == ok. Entries may now name an fqdn outside root_domain and add conditions. - 403: probes arbret-analytics (hourly, 130m window) and arbret-beancount (daily, 26h), pushing with the dedicated arbret token. - backups: pull from backup-pull@prd-vipy:/opt/backups/arbret/, and a backup-dump_arbret endpoint. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
20 KiB
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:
- Live on a shared host without stepping on it (§1).
- Push two results to Gatus: analytics and backup.
- 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
counterweightandbackup-pull, and theirauthorized_keys. In particular don't create, modify or authorise keys forbackup-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 witharbret-and there's no risk of collision.
Free for you
- PostgreSQL. We run no Postgres on this host, so the system
postgresqlpackage and cluster are yours. /opt/arbret/,/etc/arbret/, thearbretsystem 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:<port> 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
POSTs a result to Gatus, and Gatus alarms if a result isfalseor 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}
KEYissanitize(group) + "_" + sanitize(name), where sanitize lowercases and turns any of/ _ . , space # + &into-. Use the exact keys in the table below.success:trueorfalse.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 likeok 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:
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.comresolves to prd-vipy, checked every 6 hours.arbret.comregistration 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=<token from §4>
/usr/local/bin/arbret-gatus-push (root:root 0755):
#!/usr/bin/env bash
# Usage: arbret-gatus-push <gatus-key>
# Meant for ExecStopPost=, where systemd provides SERVICE_RESULT / EXIT_STATUS.
# Can also be called by hand: SERVICE_RESULT=success arbret-gatus-push <key>
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:
[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 is0600.- 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 <recipient> -> /opt/backups/arbret/arbret_<ts>.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 arestricted 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.
- Directory:
/opt/backups/arbret/, ownerroot, groupbackup-pull, mode0750. Don't change/opt/backups/itself (root:root 0755, ours). Thebackup-pullgroup exists before your playbook runs; if it doesn't, fail loudly rather than creating it. - Filename:
arbret_YYYYmmdd_HHMMSS.dump.age, timestamp in the host's local time (UTC). The prefix must be exactlyarbret_, because the box's prune and freshness checks glob onarbret_*, and it reads the dump time from the filename. - Encryption:
age -r <recipient>in the pipe (§4). Nothing unencrypted is ever written to disk. - Atomicity: write
<file>.partial, set ownership and mode, thenmvit 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. - File ownership:
root:backup-pull, mode0640, on both the.partialand 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. set -euo pipefail. Withoutpipefail, a failingpg_dumppiped into a succeedingageexits 0 and produces a tiny, valid-looking, useless artefact.- Timing: finished before 04:00 UTC. 02:00 is good.
- Local prune: delete
arbret_*.dump.ageolder than ~7 days, plus stalearbret_*.partialfiles at the start of each run. Put nothing else in the directory: no subdirectories, logs or checksums. - Report the outcome to Gatus on every exit path (§3.5).
- No service stop is needed.
pg_dumptakes a consistent snapshot online. Don't stoparbret-serverfor 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):
#!/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:
# /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
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:
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
- The local port arbret listens on (for the
arbret.comvhost), and whetherwww.arbret.comshould be served or redirected. - The exact
/api/healthresponse when healthy, and confirmation that it returns a non-200 when the DB is down. - The analytics timer's schedule (sets the heartbeat).
- Whether port 4117 over the tailnet is still needed.
- The final unit names, if not
arbret-server,arbret-worker,postgresql. - Whether staging should be backed up, and whether staging pushes are in scope.
- 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.