Backup & restore

One thing to back up: the PostgreSQL database. Losing Redis loses nothing durable; losing PostgreSQL loses the fleet.

What backs up

pg_dump in custom format (-Fc) against the same schema-owning connection string Alembic uses for migrations - not a separate backup credential. Custom format means a restore can be selective (pg_restore -t <table>) rather than always a full replay.

  • backup-portrait.sh writes a timestamped .dump to the backup directory (default /opt/portrait/backups) atomically - via a .partial rename - so a run killed mid-dump can never look like a complete backup. It prunes anything older than the retention window (default 14 days).
  • restore-portrait.sh <dump-file> restores into a test database (portrait_test) by default. It never targets the live database unless you explicitly tell it to.
  • A systemd timer runs the backup daily at 03:30 local time, with Persistent=true - a run missed because the host was asleep fires as soon as the timer is next evaluated.

Installing the backup job

These scripts and units are version-controlled references, applied by hand - Portrait’s packaging does not install timers automatically yet:

sudo cp packaging/postgresql/backup-portrait.sh packaging/postgresql/restore-portrait.sh /opt/portrait/
sudo chown portrait:portrait /opt/portrait/backup-portrait.sh /opt/portrait/restore-portrait.sh
sudo chmod +x /opt/portrait/backup-portrait.sh /opt/portrait/restore-portrait.sh
sudo cp packaging/systemd/portrait-backup.service packaging/systemd/portrait-backup.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now portrait-backup.timer

The restore drill

Do this periodically, not only when something breaks:

sudo -u portrait /opt/portrait/restore-portrait.sh /opt/portrait/backups/portrait-<timestamp>.dump

This restores into portrait_test, never the live database. Confirm row counts and spot-check a few records against the live database. If portrait_test does not exist yet, either run the test suite once (it creates it as a side effect) or have someone with PostgreSQL admin rights run CREATE DATABASE portrait_test OWNER portrait; once by hand - the restore script deliberately cannot create databases itself.

Restoring over production

This is destructive and needs a human decision each time, so it is not a flag on the default path. Stop the writers first, then restore with the live database as the explicit target:

sudo systemctl stop portrait-api portrait-alert-scheduler portrait-profile-scheduler
PORTRAIT_RESTORE_DB=portrait sudo -u portrait -E /opt/portrait/restore-portrait.sh /opt/portrait/backups/portrait-<timestamp>.dump
sudo systemctl start portrait-api portrait-alert-scheduler portrait-profile-scheduler

What this does not give you

Point-in-time recovery. A pg_dump approach means you can lose up to a day of activity between backups. This is a deliberate v1 trade - it needs no changes to the PostgreSQL configuration (no WAL archiving target to provision and secure) and matches the “runs at the floor” sizing rule. If a real incident ever makes a day of loss unacceptable, continuous WAL archiving is the thing to add.