Backup & Restore
PostgreSQL is the system of record; object storage holds media. Both must be backed up.
Backups
- Postgres:
scripts/backup-postgres.shtakes apg_dumpcustom-format backup and prunes old dumps beyond a retention count (default 14). It runspg_dumpinside the Postgres container itself (docker exec/podman exec) rather than relying on a host-installedpg_dump— this matters in practice, not just in theory: a host tool newer than the pinned server version can emit SQL (e.g.SET transaction_timeout = 0;, added in PG17+) that an older server'spg_restorerejects, which is exactly what happened while building this tooling. In Coolify, also enable scheduled volume backups as a second, storage-level layer; ship dumps to off-site object storage. - MinIO/S3: bucket versioning + periodic sync to a second region/bucket. Not yet automated by a script in this repository.
- Keycloak/OpenFGA: back the Postgres they use (or their own stores).
Restore procedure
scripts/restore-postgres.sh <dump-file> <target-db-name>restores a dump into an existing target database, usingpg_restoreinside the Postgres container for the same version-matching reason as the backup script.- Point
DATABASE_URLat the restored instance; runapi migrate up(idempotent). - Restore MinIO bucket from the versioned copy.
- Verify
/readyz, run smoke tests, confirm audit/outbox continuity.
Verifying a restore
scripts/verify-backup-restore.sh automates the actual drill end to end: it takes a
fresh backup, creates a throwaway database (never the real one), restores the backup
into it, compares row counts against the source for a set of key tables, then always
drops the throwaway database — including on failure, via a trap — so a failed drill
never leaves stray state behind. It exits non-zero on any mismatch or failure, so it is
safe to run on a schedule and alert on a non-zero exit code.
DATABASE_URL=postgres://buddora:buddora@localhost:5432/buddora?sslmode=disable \
POSTGRES_CONTAINER=buddora_postgres_1 \
./scripts/verify-backup-restore.sh ./backups
This is what makes a backup verified rather than merely produced — a corrupt or
version-incompatible dump is only caught here, not by pg_dump exiting 0.
Current status: the backup, restore, and verified-restore-drill scripts exist, are tested against real Postgres, and are safe to run on a schedule. What is not yet done: actually wiring one of them into a Coolify scheduled task or cron. This is now the only thing standing between "the drill works" and "backups happen" — the sentence here until 2026-08-28 said the repository "has not been deployed through Coolify at all yet", which stopped being true well before then. It is deployed; nothing schedules the backup.
MinIO/S3 backup automation is also still absent.
Real drill evidence (2026-07-31)
The full drill was actually run against the local dev stack's live shared Postgres
(buddora_postgres_1) — not a description of what it would do — timed end to end, with
row counts and a specific record's field values compared between source and restored
copy. The scratch/throwaway restore targets were dropped immediately after verification;
the live buddora database was never written to.
Run 1 — scripts/verify-backup-restore.sh end to end (backup → create throwaway db
→ restore → compare 6 tables' row counts → drop throwaway db):
$ time POSTGRES_CONTAINER=buddora_postgres_1 \
DATABASE_URL=postgres://buddora:buddora@localhost:5432/buddora?sslmode=disable \
./scripts/verify-backup-restore.sh ./backups
backup-postgres: done (312K) -> ./backups/buddora-20260731T070541Z.dump
verify-backup-restore: creating throwaway database dbrestore_drill_20260731070540_16320
verify-backup-restore: restoring backup into throwaway database
verify-backup-restore: OK: animal row count matches (617)
verify-backup-restore: OK: organization row count matches (108)
verify-backup-restore: OK: organization_membership row count matches (55)
verify-backup-restore: OK: safety_lost_report row count matches (212)
verify-backup-restore: OK: audit_event row count matches (471)
verify-backup-restore: OK: schema_migrations row count matches (16)
verify-backup-restore: RESTORE DRILL PASSED
verify-backup-restore: dropping throwaway database dbrestore_drill_20260731070540_16320
real 0m9.569s
Run 2 — backup and restore timed separately, plus a field-level check the script
itself doesn't do (it only compares counts): a specific real animal record
(c224482f-41c1-44c8-adcd-bd104dff4773, "Rex", a Labrador) compared byte-for-byte between
the live database and a restored copy in a scratch database named buddora_restore_drill
(dropped immediately after), and row counts extended to 8 tables:
| Step | Real measured time |
|---|---|
backup-postgres.sh (pg_dump, 312 KB dump) | 2.08 s |
restore-postgres.sh (pg_restore into a fresh scratch db) | 4.17 s |
| Full drill incl. verification + cleanup (Run 1) | 9.57 s |
animal, organization, audit_event, safety_lost_report, account_suspension,
outbox_event, moderation_report, and privacy_request all matched exactly
(617 / 108 / 471 / 212 / 24 / 520 / 116 / 14 rows respectively); the sampled record's
id/name/species/breed/created_at were identical between source and restored
copy.
Against ROADMAP.md's stated targets (RPO 15 min, RTO 2 h): both are cleared by a wide
margin at this scale — a ~2 s backup step means backups can run far more often than
every 15 minutes if desired, and a ~10 s full restore-and-verify drill is nowhere near the
2-hour RTO budget. Read that margin honestly, not as a production capacity claim: the
database backed up here is ~1,600 total rows across the tables checked and a 312 KB dump.
A production database will be orders of magnitude larger, and pg_dump/pg_restore
duration scales with data volume, not row count alone (large bytea/JSON payloads,
indexes to rebuild, etc. all add real time pg_restore has to spend that this drill's
tiny dataset never exercises). This run proves the mechanism is correct and the
tooling works end to end for real — it does not prove a multi-GB production database
restores within 2 hours; that requires a drill against production-scale data volume,
which does not exist yet in this environment (there is no such environment yet — see
"Current status" above).
Cadence
Automate daily; rehearse a full restore quarterly and record the RTO/RPO achieved.
scripts/verify-backup-restore.sh can run this drill on any cadence you schedule it —
daily is cheap enough in practice that there is little reason not to.
Nothing schedules it today. docs/architecture/OBSERVABILITY.md records that
backup/restore-drill staleness deliberately has no alert rule for exactly this reason: there
is no signal to alert on until something runs the drill on a schedule. Treat the cadence above
as the intent, not as a description of what is happening.