Runbook: backup and restore
Audience: site operator / MSP admin — you host Marula yourself and own its deployment.
This is the first authored page of the
deploy/runbooks/series. The scaffold and future runbook index live atdeploy/runbooks/README.md.
Everything in Marula lives in the Postgres database, and once S3 object storage is enabled also the object-storage (Garage/S3) bucket. Back both up or back neither up alone: document attachments (content-addressed uploads) and versioned documents reference object storage, while everything else — tickets, time, invoices, FX rates, the provenance and audit ledges, AI decision answers, customization rows — is rows in Postgres.
Backup
Three tiers — pick by how much you can lose. The compose backup profile
(runner at deploy/backup/) automates every tier below:
| Tier | Tool | You get | Cost |
|---|---|---|---|
| 0 | manual pg_dump (below) | last-night-to-now restore point | minutes lost per backup |
| 1 | restic snapshots (deploy/backup/restic.env.example) | deduped, encrypted snapshots of the object store + the nightly dumps — --keep-daily 7 --keep-weekly 4 | scheduled container, one passphrase |
| 2 | WAL-G (deploy/backup/wal-g.env.example) | point-in-time recovery — restore to any second, not any night; WAL shipped continuously to the same S3-compatible bucket | archive_command + retention discipline |
docker compose -f deploy/docker-compose.yml exec postgres \
pg_dump -U marula -d marula -Fc -f /tmp/marula.dump
docker cp marula-postgres-1:/tmp/marula.dump marula-$(date +%F).dumpNotes the runbook enforces:
- Dump as the table owner (
marula), notmarula_app— the RLS-constrained app role must never be able to read across tenants, which also means it cannot produce a complete dump. - Dump with a client that matches the server's major version. The backup
runner runs inside the postgres image on purpose; a diverged local
pg_dumprefuses the archive outright. - If you run
OBJECT_STORE=s3with Garage, the restic leg snapshots the garage data/meta volumes in the same window so attachments and rows stay as consistent as two separate stores allow — this is not atomic: a few seconds of drift between the WAL cutoff and the object snapshot is possible. Both directions of that drift have a repair path now: the worker's object integrity pass (MARULA_OBJECT_GC=on) reclaims objects no row references and marks rows whose bytes are gone, and re-uploading the same file clears the mark, because the object key is that file's digest. See data lifecycle §3. - The same pass snapshots the local object store, which lives in the
objectdatavolume now — it used to be a directory inside a read-only container, which could not survive a restart. - Take a dump before every upgrade step (see upgrade step 3). The upgrade preflight runs against a database copy — supply it with a fresh one.
WAL-G specifics (PITR)
-
Copy
deploy/backup/wal-g.env.example→wal-g.env(compose: same for restic), fill in the Garage/S3 coordinates, and build with WAL-G:docker compose --profile backup build --build-arg INSTALL_WAL_G=1 backup. The template also takes the database connection —PGHOST,PGPORT,PGUSER,PGDATABASE, and aPGPASSWORDorPGPASSFILE. Those are libpq's own names, so pg_dump, psql and wal-g all read the same values. -
The
PGUSERrole needsREPLICATIONin addition to read access. Abackup-pushwith no data directory streams the base backup over theBASE_BACKUPprotocol, which Postgres accepts only from a role holding that attribute, and the replication connection also needs apg_hba.confline —initdbwriteshost replicationfor loopback only, so a backup container on another address is refused until the rule exists:CREATE ROLE marula_backup LOGIN PASSWORD '<generated>' REPLICATION; GRANT CONNECT ON DATABASE marula TO marula_backup; GRANT pg_read_all_data TO marula_backup;# /var/lib/postgresql/data/pg_hba.conf, then SELECT pg_reload_conf(); host replication marula_backup all scram-sha-256The compose
marularole is the cluster superuser and holds both, so a single-host install that uses it needs no extra grant. The restore drill is a different role:restore.shcreates and drops databases and roles, and itsPGUSERneedsCREATEDBandCREATEROLEfor that. -
Enable WAL archiving by starting postgres with
-c archive_command='wal-g wal-push %p'— compose-override file documented indeploy/backup/compose.walg.example.yml. -
wal-g backup-pushruns on the scheduled nightly pass. Retention viaWALG_RETAIN_FULL: after each push the runner callswal-g delete retain FULL <n> --confirm. The flag matters — without itwal-g deleteonly lists what it would remove, so a destination fills up while every night's log says retention ran. To rehearse before letting it delete, setWALG_RETENTION_DRY_RUN=1: the runner then calls the same command without--confirmand logsretention DRY RUN. -
Alert on archive_command silence. A broken archive pipe fails silently and quietly degrades you from "any second" back to "last night." If WAL shipping shows no progress for >24h (check the archiver's
pg_stat_archiverview), page yourself.
Restore
-
Stop the services (
docker compose … stop core-api worker). -
For the last-night tier: plain
pg_restore(below). For PITR: follow wal-g restore — stop Postgres,wal-g backup-fetch, replay WAL to the recovery target, then start. Stop at a point after the damage, not before it (recovery_target_timedefaults to "latest consistent state"). -
Rehearse before you need it:
sh deploy/backup/restore.sh /tmp/marula.dumpIt restores into a scratch DB and asserts the restore is real: the schema lands (with RLS enabled on every org-scoped table), and when the source stack is reachable the row counts of every public table match, which catches a dump that loses rows in transit. Set
PGDATABASE=marula(plusPGSOURCE_HOST/PGSOURCE_PORTagainst a different server) to turn the parity check on. Without it the leg stops after the schema checks. It drops the scratch DB when done — a backup that has never restored is not a backup. Thebackupprofile already runs this drill on a cadence (DRILL_EVERY_DAYS, default 7) and records each run, with its duration, inops.backup_drill_runs; RPO and RTO explains what that duration is for and what the drill cannot prove. -
Restore the garage data/meta volumes or bucket contents if you use object storage.
-
Start the services. Do not re-run migrations between restore and start unless the restored dump predates a schema change you have since applied — migrations are forward-only and append-only, history is never edited, so any deployed database replays them identically.
Verify a restore
Boot a scratch stack pointed at the restored volume and log in. Confirm: an
open ticket renders in the workbench, today's time entries exist, and
/audit shows the append-only ledger intact. The upgrade-fixture CI
restores are production-like snapshots — treat your restore rehearsal the
same way, not as a theoretical step.