Skip to content

Backup and Recovery

c0dewhacker edited this page Aug 19, 2026 · 1 revision

Backup and Recovery

Roomer's persistent state lives in two places, both of which need backing up:

  • PostgreSQL — every booking, user, asset, floor plan record, webhook config, etc.
  • The uploads volume (uploads_data in Compose, /app/uploads in the container) — floor plan images/PDFs/DXF files and lease documents.

Losing the database loses all structured data. Losing the uploads volume leaves every floor plan and lease document referenced-but-missing — the database still has the file paths, just nothing behind them. Back up both, on the same schedule, so a restore always pairs a DB snapshot with the uploads that existed at the same point in time.

This page covers the Docker Compose deployment (see [[Production Deployment]]) as the primary case, with a section for [[Kubernetes Deployment]] below.

PostgreSQL backups

Logical backups (pg_dump) — do this at minimum

docker compose exec -T postgres pg_dump \
  -U "${POSTGRES_USER:-roomer}" \
  -d "${POSTGRES_DB:-roomer}" \
  --format=custom \
  > roomer-$(date +%Y%m%d-%H%M%S).dump

--format=custom supports pg_restore's selective/parallel restore and is already compressed. (The Production Deployment 0.3.0 upgrade notes use a plain-SQL pg_dump -U roomer roomer > file.sql for a quick pre-upgrade snapshot — either format works for a one-off; --format=custom is the better default for a scheduled backup you may need to restore later.)

Schedule it with cron or a systemd timer on the Docker host:

# /etc/cron.d/roomer-backup — daily at 02:00
0 2 * * * root cd /path/to/roomer && docker compose exec -T postgres pg_dump -U roomer -d roomer --format=custom > /backups/roomer-$(date +\%Y\%m\%d).dump 2>> /var/log/roomer-backup.log

Ship the resulting file off the Docker host — a local-disk-only backup doesn't survive a host failure.

Point-in-time recovery (WAL archiving) — optional, for tighter RPO

A daily pg_dump risks losing up to a day of data if the DB fails right before the next scheduled dump. If that's not acceptable, enable WAL archiving so you can replay to any point in time instead of just the last dump. This needs config changes the default compose file doesn't make — somewhere to put WAL segments:

# docker-compose.yml — postgres service
services:
  postgres:
    volumes:
      - postgres_data:/var/lib/postgresql
      - wal_archive:/wal-archive       # add
    command: >
      postgres
      -c wal_level=replica
      -c archive_mode=on
      -c archive_command='test ! -f /wal-archive/%f && cp %p /wal-archive/%f'

volumes:
  postgres_data:
  uploads_data:
  wal_archive:                          # add

With this in place, a full PITR setup is a periodic base backup (pg_basebackup — a physical copy of the data directory, not pg_dump) plus the continuously-archived WAL segments; restoring starts Postgres from the base backup with a recovery_target_time and replays WAL up to that point. This is meaningfully more operational complexity than pg_dump — only take it on if your RPO actually requires it. For most self-hosted Roomer instances, a daily pg_dump plus the uploads backup below is enough.

Uploads volume backup

Option 1: rsync/rclone to secondary storage

# Find the actual volume name first — it's prefixed with your compose
# project name, which defaults to the directory docker-compose.yml lives in
# ("roomer" if you cloned the repo as-is, but check rather than assume):
docker volume ls --filter name=uploads_data

MOUNTPOINT=$(docker volume inspect roomer_uploads_data --format '{{ .Mountpoint }}')
rsync -a "$MOUNTPOINT/" /backups/roomer-uploads/

# Or off-host via rclone, to any of its 70+ supported backends
rclone sync "$MOUNTPOINT/" remote:roomer-backups/uploads/

Option 2: migrate to S3-compatible object storage

If durability matters more than the simplicity of a local volume, point Roomer's file storage at S3-compatible storage instead of a local path and let the storage backend's own durability/replication do the work — see Configuration Reference for current file-storage backend options before relying on this, since they may have changed since this page was written.

Kubernetes deployments

The chart runs Postgres as a StatefulSet (via the postgresql.enabled bitnami sub-chart — see Kubernetes Deployment) with its own PVC, plus a separate PVC for uploads.

# Postgres dump — replace <release> with your Helm release name.
# Connect as the app's own DB user (POSTGRES_USER on the postgres pod's env,
# "roomer" by default) — NOT the "postgres" superuser. This chart's bitnami
# postgres sub-chart doesn't enable password auth for "postgres" over the
# local socket, only for the app user; `pg_dump -U postgres` fails with
# "password authentication failed" even with the correct postgres-password
# secret. `-U <POSTGRES_USER>` with the `password` secret key works.
kubectl exec -n roomer <release>-postgresql-0 -- \
  bash -c 'PGPASSWORD="$(cat /opt/bitnami/postgresql/secrets/password)" pg_dump -U roomer -d roomer --format=custom' \
  > roomer-$(date +%Y%m%d-%H%M%S).dump

# Uploads — copy out of the PVC via the running API pod
kubectl cp -n roomer <api-pod-name>:/app/uploads ./roomer-uploads-backup/

If you already run Velero in the cluster, hook Roomer's backups into your existing Velero schedule instead of a separate cron job. A raw PVC snapshot of a live Postgres data directory isn't safely restorable on its own — Velero's pattern is a pre-backup hook that runs pg_dump to a file just before the snapshot/restic backup captures the volume, so what's captured is always a consistent dump rather than a mid-write snapshot. Add a pre.hook.backup.velero.io/command annotation (running the pg_dump command above) via the postgres sub-chart's postgresql.primary.podAnnotations value, and a backup.velero.io/backup-volumes annotation on the uploads volume's pod. This isn't a first-class values.yaml toggle in the chart — most self-hosted Roomer deployments don't run Velero, and the annotations above are enough to wire in for the ones that do.

Restore runbook

  1. Stop the API so nothing writes to the DB mid-restore:
    docker compose stop api        # Compose
    kubectl scale deploy roomer-api -n roomer --replicas=0   # Kubernetes
  2. Restore the database into a fresh/empty DB:
    docker compose exec -T postgres pg_restore \
      -U "${POSTGRES_USER:-roomer}" -d "${POSTGRES_DB:-roomer}" \
      --clean --if-exists < roomer-20260101-020000.dump
    --clean --if-exists drops existing objects first so the restore doesn't collide with whatever's currently in the target DB.
  3. Restore the uploads volume — copy the backed-up files back into the volume's mountpoint (or docker cp/kubectl cp into the running container if the volume itself wasn't recreated).
  4. Start the API back up and watch the logs — it runs prisma migrate deploy on boot, which no-ops if the restored DB is already current, or applies any migrations newer than the backup if you're restoring an older dump onto a newer image.
  5. Verify data integrity — log in, spot-check a few floor plans actually render (confirms the uploads restore matches the DB restore), check the most recent bookings/audit log entries match what you'd expect from the backup's timestamp.
  6. Re-seed only if restoring into a genuinely empty environment (no backup at all) — the idempotent seed (seed.js) that creates the admin account runs on every boot and doesn't touch pre-existing rows, so it's safe to leave running against a freshly-restored DB regardless.

Recommended frequency and retention

Scale this to how much data loss your organisation can tolerate and how large the deployment is. A reasonable starting point:

Frequency Retention
pg_dump Daily 30 days local, 90 days offsite
Uploads sync Daily (paired with the DB dump) Same as DB — mismatched ages between the two make restores confusing
Base backup + WAL (if using PITR) Weekly base backup, continuous WAL 7–14 days of WAL

Test a restore periodically — a backup you've never restored from is a hope, not a plan. Quarterly is a reasonable cadence for most deployments; do it in a scratch environment, not production.

Clone this wiki locally