backup and restore - BaryoDev/barakoCMS GitHub Wiki
Backup and restore
barakoCMS does not back itself up. The database is yours, and so is the backup. This page is what runs, where it writes, and how to restore, with the restore proved rather than assumed.
scripts/restore-check.sh runs this whole procedure in CI on every pull request: it takes a backup
with the same script the deployments run, destroys the database, restores it, and boots the app
against the result.
What runs
Every deployment path takes a nightly backup that is checked before it is published.
| Deployment | Service | Database | Where backups go |
|---|---|---|---|
docker-compose.yml (development) |
app |
barako_cms |
./backups on the host |
docker-compose.prod.yml |
app |
${DB_NAME:-barako_cms} |
the postgres_prod_backups volume |
quickstart/docker-compose.yml |
api |
${DB_NAME:-barakocms} |
the backups volume |
k8s/ |
Deployment barako-cms |
barako_cms |
the barako-backups PVC |
The three compose stacks run scripts/backup-cron.sh. The quickstart runs its own copy,
quickstart/scripts/backup-cron.sh, because that folder is meant to be copied out of the
repository; scripts/check-quickstart-backup-script.sh fails CI when the two differ. The Kubernetes
CronJob carries the same logic inline, because a CronJob has no repository to mount; if you change
one, change both.
Defaults: 02:00 daily, 14 days retained in production and 7 elsewhere. Override with
BACKUP_CRON_SCHEDULE and BACKUP_KEEP_DAYS.
The service name, database name and backup location all differ between these stacks. That is why the restore section below is split by deployment rather than given once: a restore command copied from the wrong row silently targets the wrong database.
Why the script is not pg_dump | gzip
Because that reports success when it has failed. gzip succeeds on empty input, and in a pipeline
its exit code is the one you get, so a dump that could not connect leaves a 20-byte archive and a
green log. That is not hypothetical here: the credentials were wrong for months and every nightly
backup was an empty gzip claiming to be fine.
So the script dumps to a plain file first, checks pg_dump's own status, requires the result to
exceed a minimum size, greps it for the PostgreSQL database dump header, gzips it, tests that the
archive decompresses, and only then moves it to its final name. A failed run leaves nothing behind
that looks like a backup. Rotation happens only after a success, so a bad night never deletes the
last good copy.
Restoring
Stop the application first. A restore into a database the app is writing to will not be the database you backed up.
docker compose, development stack
docker compose stop app
gunzip -c ./backups/barako_backup_2026-08-30_02-00-00.sql.gz \
| docker compose exec -T postgres psql -U postgres -d barako_cms -v ON_ERROR_STOP=1
docker compose start app
docker compose, production stack
Archives live in a named volume rather than a host directory, so read them from inside the backup container:
docker compose -f docker-compose.prod.yml stop app
# List what is there.
docker compose -f docker-compose.prod.yml exec db-backup ls -1 /backups
docker compose -f docker-compose.prod.yml exec -T db-backup \
sh -c 'gunzip -c /backups/barako_backup_2026-08-30_02-00-00.sql.gz' \
| docker compose -f docker-compose.prod.yml exec -T postgres \
psql -U "${DB_USER:-postgres}" -d "${DB_NAME:-barako_cms}" -v ON_ERROR_STOP=1
docker compose -f docker-compose.prod.yml start app
quickstart stack
The service is api, not app, and the database defaults to barakocms rather than barako_cms:
cd quickstart
docker compose stop api
docker compose exec -T db-backup sh -c 'gunzip -c /backups/barako_backup_2026-08-30_02-00-00.sql.gz' \
| docker compose exec -T postgres psql -U "${DB_USER:-postgres}" -d "${DB_NAME:-barakocms}" -v ON_ERROR_STOP=1
docker compose start api
Kubernetes
kubectl -n barako-cms scale deployment/barako-cms --replicas=0
kubectl -n barako-cms run restore --rm -it --restart=Never \
--image=postgres:16-alpine \
--overrides='{"spec":{"volumes":[{"name":"b","persistentVolumeClaim":{"claimName":"barako-backups"}}],
"containers":[{"name":"restore","image":"postgres:16-alpine","stdin":true,"tty":true,
"volumeMounts":[{"name":"b","mountPath":"/backups"}]}]}}' \
-- sh -c 'gunzip -c /backups/$(ls -1 /backups | tail -1) \
| PGPASSWORD=$PGPASSWORD psql -h postgres-service -U postgres -d barako_cms -v ON_ERROR_STOP=1'
kubectl -n barako-cms scale deployment/barako-cms --replicas=2
ON_ERROR_STOP=1 is not optional. Without it psql reports success after skipping every statement
it could not apply, which is the same failure shape as the empty gzip.
Restoring into a database that is not empty
Do not. Restoring over an existing schema can appear to work because the objects the dump failed to carry are already there, so you learn nothing about whether the archive is complete.
Drop and recreate first:
drop database barako_cms with (force);
create database barako_cms;
The CI drill does exactly this, and asserts the database has zero tables before restoring, so a restore that quietly relies on leftovers fails there rather than in front of a customer.
After a restore
Check three things, in this order:
- The app boots. It applies the Marten schema at startup under
CreateOnly, so a restore that lost an object fails loudly here rather than later. If it exits non-zero, read the error: it names the object. - You can sign in. Proves the users, roles and password hashes came back.
- Content is served.
GET /api/contentsshould report the row count you expect.
The projection daemon resumes from mt_event_progression, which the dump carries, so workflows do
not re-fire for events that were already processed. If that table were lost, the first boot would
replay every event and send every email again, which is why the drill checks the app rather than
just the row counts.
What is not covered
- Point-in-time recovery. These are nightly snapshots. Losing up to a day of writes is the stated exposure, and closing it means WAL archiving, which is a Postgres decision rather than a barakoCMS one.
- Files. The Files module stores bytes in Postgres by default, so they are inside the dump. With the S3 module configured they are not, and that bucket needs its own backup.
- An RPO or RTO. There is no measured recovery time to quote in a contract. The CI drill gives a lower bound on a small database and nothing more.
Generated from docs/backup-and-restore.md by scripts/wiki-sync.sh. Edit the doc in the repository, not this page.