Skip to Content
GuidesBackup & Restore

Backup and Restore

PicPeak’s built-in backup service runs full or incremental backups on a cron schedule, writes to local disk / rsync / S3-compatible storage, and tracks file checksums so that subsequent runs only upload what changed.

Manual backup (DB + files)

If you don’t want the built-in service, you can backup manually with one-shot commands:

Database

docker exec picpeak-postgres pg_dump -U picpeak picpeak_prod > backup/db_$(date +%Y%m%d_%H%M%S).sql

Files

tar -czf backup/photos_$(date +%Y%m%d_%H%M%S).tar.gz events/ thumbnails/

For SQLite:

cp data/picpeak.db backup/picpeak_$(date +%Y%m%d_%H%M%S).db

Built-in backup service

Configure under Admin → Backup & Restore.

Settings reference

KeyDefaultWhat it does
backup_enabledfalseMaster toggle.
backup_schedule(empty)Cron expression. Either standard (0 2 * * * = 2 AM daily) or one of the named presets (see below).
backup_destination_type(required)local / rsync / s3
backup_destination_pathstorage/backupsWhere backups land for local destinations.
backup_manifest_formatjsonjson or yaml. Manifest is the file index for the backup.
backup_retention_days30Backups older than N days are deleted on the next successful run.
backup_incrementaltrueWhen on, files that haven’t changed since the last backup are skipped. Saves bandwidth + storage.
backup_include_archivedtrueInclude events/archived/ in the file scan.
backup_include_databasetrueInclude a pg_dump (or SQLite copy) in the backup.
backup_max_file_size_mb5000Files above this size are skipped (with a warning) — prevents one corrupt huge file from blocking a backup.
backup_email_on_successfalseEmail admin after every successful backup.
backup_email_on_failuretrueEmail admin after a failed backup.

S3 destination settings

KeyDefaultWhat it does
backup_s3_bucket(required)Bucket name.
backup_s3_regionus-east-1AWS region. For MinIO use any string.
backup_s3_endpoint(empty)Custom endpoint URL for MinIO, R2, B2, etc.
backup_s3_access_key(required)IAM access key.
backup_s3_secret_key(required)IAM secret.
backup_s3_prefixbackupsKey prefix. Backups stored under {prefix}/YYYY/MM/DD/backup-{id}/.
backup_s3_force_path_stylefalseOn for MinIO. Off for AWS S3.
backup_s3_ssl_enabledtrueHTTPS to the S3 endpoint.

Rsync destination settings

KeyDefaultWhat it does
backup_rsync_host(required)Hostname or IP of the rsync target.
backup_rsync_user(empty)SSH username.
backup_rsync_path(required)Remote path.
backup_rsync_ssh_key(empty)Local path to a private SSH key. StrictHostKeyChecking=no is set on the SSH command.

Full vs incremental

FullIncremental
What’s uploadedEvery file matching the scan filterOnly files whose checksum changed since the last backup
ManifestComplete file list with checksumsSame shape, plus parent_backup_id linking to the previous run
When to useFirst run, periodic ground-truth runs (weekly?)Routine daily runs

The first ever backup is always a full backup. After that, when backup_incremental=true, the service compares each file’s current checksum to the value stored in the backup_file_states table. Unchanged files are skipped.

Manifest format

Every backup writes a manifest alongside the photos. The manifest is the index — it tells the restore process what to expect.

version: '2.0' backup: id: backup-20260428-022150-a1b2c3d4 type: incremental parent_backup_id: backup-20260427-022150-... timestamp: 2026-04-28T02:21:50.000Z files: count: 1245 manifest: - path: events/active/wedding-smith/DSC_0001.jpg size: 4523894 checksum: sha256:... - ... database: type: postgresql schema_version: 097_add_customer_phone verification: total_checksum: sha256:... # SHA-256 of the manifest itself

Format is json or yaml per backup_manifest_format. JSON is more compact; YAML is human-readable.

S3 prefix walker (auto-import)

When PicPeak is reading backups from S3 (e.g. you migrated to a new server and want to restore from your existing S3 backup bucket), the S3 Prefix Walker scans the bucket under your configured backup_s3_prefix and surfaces every backup it finds.

How it works:

  1. Lists all objects under {prefix}/.
  2. Filters for objects matching the dated structure (YYYY/MM/DD/backup-{id}/manifest.{json|yaml}).
  3. Polls again 30 seconds later — this is the 2-poll eventual-consistency gate: S3 may not list very-recently-uploaded objects, so we wait once and re-check before declaring “no more backups”.
  4. Returns the list to the admin UI for selection.

Triggered manually from the Backup & Restore page → Browse S3 action. There is no cron variant — it’s always admin-initiated.

Retention & rotation

After every successful backup the service enumerates existing backups (local or S3) and deletes any whose backup.timestamp is older than backup_retention_days. For S3 destinations this means deleting the entire {prefix}/YYYY/MM/DD/backup-{id}/ prefix plus the manifest sibling.

If you want to keep a permanent ground-truth backup, copy it to a separate prefix or bucket — anything inside the rotated path is fair game.

Restore

Test your restore process before you need it. The UI restore button is straightforward but the manual paths below are unforgiving — practice on a dev instance first.

From the admin UI

The Backup & Restore page lists every available backup with a Restore button. Behavior:

  1. Downloads the backup files from the destination.
  2. Restores the database (overwrites the existing DB).
  3. Restores files to events/active/, events/archived/, thumbnails/ etc.
  4. Verifies checksums against the manifest.
  5. Reports any files that failed verification.

The UI restore is a one-shot, full-rollback operation — run it on a fresh instance, not over a live one with newer data, unless you’ve thought through the consequences.

Manual restore — DB

cat backup/db_20260428_022150.sql | docker exec -i picpeak-postgres psql -U picpeak picpeak_prod

Manual restore — files

tar -xzf backup/photos_20260428_022150.tar.gz -C /path/to/storage/

Coverage tab

Backup → Coverage answers “what will the next backup actually include?” without having to run it:

  • Database — inline-dump mode + last dump timestamp + staleness check.
  • Configured paths — one row per backup-path entry with its current coverage status (will-scan / skipped-by-toggle / skipped-by-feature-flag / missing-on-disk).
  • Drift detection — flags top-level directories under STORAGE_PATH that exist on disk but have no matching backup-path row. This is the canary for “a feature shipped without a matching backup row” — the most common cause of silent data loss on older installs.

The Coverage tab auto-fetches on open. If everything is green, your next backup will capture what you’d expect.

Integrity tab

Backup → Integrity verifies that every *_path column on quotes / contracts / invoices / signatures actually resolves to a file on disk, and that files with a stored *_sha256 still hash to the same value. Read-only, on-demand. Useful for:

  • Post-restore validation
  • Detecting bit-rot
  • Auditing legal-evidence artefacts before a tax review or dispute

Restoring an older backup on a newer image

PicPeak’s restore path is forward-compatible: a backup taken on an older version restores cleanly onto a newer image without any manual schema work. After loading the dump, the restore service runs the same npm run migrate:safe script that the container runs on every boot. Any migrations added between the backup’s snapshot and the current image are applied inline, against the freshly-restored DB, before the restore is reported as complete.

Net effect: even if a later release shipped a migration that adds a table, after restore your install has that table (empty), the right indexes, and any seed rows the migration emits — no “you’ll need to restart the container once” footnote. The same applies to the install-from-backup trigger below: migrations land inside the restore boundary, so the moment the server prints Server running on port 3000, the schema matches the running image.

Disaster recovery (install from a backup)

For full DR after docker compose down -v, host migration, drive replacement, or moving an install between hosts. PicPeak detects a trigger file on first boot and runs the restore before the admin UI surfaces. You open the browser, log in with your original credentials, and the install is fully populated — no onboarding wizard, no temporary admin step.

Prerequisites

  • Your install’s backup files must already be present in the /backup mount. They survive docker compose down -v because /backup is a bind mount, not a Docker-managed volume.
  • The backup must contain a database dump. The restore cannot reconstruct your CRM data, customers, quotes, invoices, or admin users from a files-only backup. Confirm by inspecting any backup-manifest-*.json and checking that database.backup_file is non-null.

How the trigger works

On every container start, PicPeak’s boot sequence checks for a trigger file in the root of the /backup mount. If found and the destination database is empty, the restore runs automatically. After a successful restore the trigger file is deleted so the next boot doesn’t redo the work. On failure the trigger file is preserved — fix the input and restart the container to retry.

The trigger file is named RESTORE_ON_INSTALL (no extension) or RESTORE_ON_INSTALL.txt — either is accepted.

Auto-pick the newest backup — create an empty trigger file:

touch /path/to/backup/RESTORE_ON_INSTALL

The boot hook scans /backup/manifests/ for backup-manifest-*.json (or .yaml) and picks the one with the most recent modification time.

Use a specific backup — write the manifest path (relative to the /backup mount root, or absolute) into the trigger file:

echo "manifests/backup-manifest-backup-20260530-190617-e9be97b3.json" \ > /path/to/backup/RESTORE_ON_INSTALL

Use this when you need an older backup — e.g. rolling back a data corruption that happened after the most recent backup ran.

Full DR walkthrough

# 1. Snapshot the backup outside the compose directory (belt + suspenders). SNAPSHOT=~/picpeak-snapshots/$(date +%Y%m%d-%H%M%S) mkdir -p "$SNAPSHOT" && cp -av /path/to/picpeak/backup/. "$SNAPSHOT/" # 2. Drop the trigger file (auto-pick newest shown). touch /path/to/picpeak/backup/RESTORE_ON_INSTALL # 3. Boot. docker compose down -v docker compose up -d docker compose logs -f backend --tail=100

When the boot log shows Install-from-backup: restore completed successfully followed by Server running on port 3000, the install is ready. Open the admin UI and log in with your original (pre-disaster) credentials.

Safety gates

Three layers prevent accidental data loss:

  1. The trigger file must exist. No auto-magic — an admin explicitly drops the file to signal intent.
  2. The destination database must be empty. If it contains any events, the hook refuses to run. The fresh-install default admin is treated as throwaway and replaced by the backup’s admin row, so a single admin user does not block the restore.
  3. Failed restores roll back to the pre-restore state. If anything fails after the DROP DATABASE step, PicPeak’s automatic rollback restores the destination from the pre-restore safety backup it took before starting.

To intentionally clobber a populated install (dev rebuilds, staging refresh), set INSTALL_FROM_BACKUP_FORCE=true on the backend service. This skips gate #2 only — the trigger-file requirement and rollback-on-failure still apply.

Recommendations

  • Run a weekly full backup + daily incrementals (cron: 0 2 * * 0 for Sunday full, 0 3 * * 1-6 for Mon–Sat incremental — manage as two separate webhooks if needed).
  • Keep at least 30 days of retention.
  • Store backups off-site — S3 in a different region than your primary, or rsync to a remote server.
  • Test the restore at least quarterly. A backup you’ve never restored isn’t a backup.
  • Monitor backup_email_on_failure notifications — the only way you’ll find out a backup is failing silently.
Last updated on