The previous split (separate docs/install-from-backup.md + separate
README link for "Disaster Recovery") fragmented what's conceptually
one workflow: backup → restore. DR is a specific scenario of restore
(the destination is wiped), not a separate feature.
This merge:
- Folds install-from-backup content into docs/backup-restore.md
as a "Disaster recovery (install from a backup)" section with
its own table-of-contents anchor.
- Adds an explicit ToC at the top so admins land on what they
need in one click.
- Frames the two restore paths up front: "live install" → wizard,
"fresh / wiped install" → trigger file. Admins encountering DR
in panic mode don't need to know to look under a separate link.
- Drops the duplicate "Disaster Recovery" README bullet. The
"Backup & Restore" blurb now mentions DR explicitly so it's
still findable via Ctrl+F on the README.
- Removes docs/install-from-backup.md (its content is now in
backup-restore.md's DR section).
Single source of truth = less risk of one doc going stale relative
to the other when the feature evolves. Maintainer-facing surface
on docs.picpeak.app shrinks back to one /guides/backup-restore page.
17 KiB
title, description, sidebar_position
| title | description | sidebar_position |
|---|---|---|
| Backup & Restore | How picpeak captures your install, where backups land, and how to recover from them — including full disaster recovery. | 2 |
Backup & Restore
picpeak's backup system captures your entire install — database, photos, CRM documents, gallery archives, and configuration — to a destination of your choice. Recovery happens through one of two paths depending on how badly things went wrong:
- The install is alive → use the Restore wizard in the admin UI to roll back to a chosen backup.
- The install is gone (host migration,
docker compose down -v, drive replacement) → use the install-from-backup trigger file convention to rebuild in one boot, with no onboarding wizard and no temporary admin step.
This guide covers both.
Table of contents
- What gets backed up
- Destinations
- Inline DB dump
- Custom backup paths
- The Coverage tab
- The Integrity tab
- Restoring on a live install
- Disaster recovery (install from a backup)
- Backup History detail
- Settings reference
- Troubleshooting
What gets backed up
Every "Run Backup Now" (manual or scheduled) produces:
-
A database dump captured inline at the start of the run. Always included by default. picpeak refuses to ship a backup without a database dump unless the operator has explicitly opted out via the
backup_database_inline_dumpsetting — see Inline DB dump below. -
Files from a configurable list of paths, declared in the
backup_pathstable:Path Default Notes events/active✓ Live gallery photo originals events/archivedgated by backup_include_archivedLong-term archive thumbnails✓ Generated thumbnails previews✓ Lightbox preview tier heroes✓ Gallery hero images uploads✓ Wet-signature contracts, imported invoices, etc. business-docs✓ CRM PDFs, signature artefacts, imported historical invoices Admins can add or remove rows from
backup_pathsto teach the walker about new feature directories — see Custom backup paths. -
A manifest JSON describing the run, written to
<destination>/manifests/backup-manifest-<id>.json. The manifest carries the database dump path, the file inventory, checksums, and per-path counters.
Destinations
picpeak supports three destination types, configured via Backup → Configuration:
- Local — files copied to a directory on the same host (default:
/backupinside the container, which is typically a bind mount). - S3 / MinIO — files uploaded via the S3 API. Supports custom endpoints (for MinIO, Backblaze B2, Wasabi, etc.).
- rsync — synchronised to a remote host over SSH.
Direct download from the admin UI is supported for local destinations; S3 backups can be retrieved via pre-signed URLs.
Inline DB dump
Every "Run Backup Now" runs pg_dump (or sqlite3 .backup) inline before walking files. This guarantees the manifest's database.backup_file is always a fresh capture, never a stale reference to a previously-scheduled dump that may not exist.
If the inline dump fails (disk full, pg_dump crash, permission error), the run aborts and writes the error to backup_runs.error_message. The UI surfaces this as a failed run — no more silent files-only manifests.
To opt out (e.g. if you have a separately-orchestrated DB backup that you trust more):
INSERT INTO app_settings (setting_key, setting_value, setting_type, updated_at)
VALUES ('backup_database_inline_dump', 'false', 'backup', NOW())
ON CONFLICT (setting_key) DO UPDATE
SET setting_value = 'false', updated_at = NOW();
With inline-dump opted out, picpeak's fail-loud guard still applies: a file backup with no recent DB dump on file (within 26 hours) will fail rather than ship a files-only manifest.
Custom backup paths
To add a new directory to the backup walker (e.g. you've shipped a feature that drops artefacts under storage/my-feature/):
INSERT INTO backup_paths (path, include_in_default, display_order, description, created_at, updated_at)
VALUES ('my-feature', true, 100, 'My new feature artefacts', NOW(), NOW());
Next "Run Backup Now" picks it up — no restart, no migration. Set include_in_default = false to temporarily disable a path without dropping the row.
The feature_flag column gates a path behind an app_settings boolean (matches how events/archived is gated by backup_include_archived). Useful when a backup path corresponds to an optional feature.
The 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_pathsentry 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_PATHthat exist on disk but have NO matchingbackup_pathsrow. This is the canary for "a feature shipped without a matching backup row" — the most common cause of silent data loss in pre-2026-05 picpeak.
The Coverage tab auto-fetches on open. If everything is green, your next backup will capture what you'd expect.
The 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 on a live install
Use this when picpeak is running and you want to roll back to a specific backup point — e.g. recovering accidentally-deleted records, reverting a bad migration, or testing a restore drill.
Backup → Restore walks you through:
- Source — Local, S3, or rsync
- Choose Backup — manifests discovered from disk (works after a fresh install where
backup_runsis empty) or from the database history - Restore Options — Full / Database only / Files only / Selective + Force + Skip Pre-Restore
- Review — surfaces validation warnings before you commit
- Restore Progress — real-time stream of the actual steps
Failures during restore trigger an automatic rollback from the pre-restore safety snapshot. The destination ends up either as the restored state OR as the original pre-restore state — never as a half-clobbered mix.
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.
Prerequisites
- Your install's backup files must already be present in the
/backupmount. They survivedocker compose down -vbecause/backupis a bind mount, not a Docker-managed volume. - The image must include the install-from-backup feature (shipped 2026-05-31 on
beta; available inmainafter the next stable release). - The backup must contain a database dump. The wizard cannot reconstruct your CRM data, customers, quotes, invoices, or admin users from a files-only backup. Confirm by inspecting any
backup-manifest-*.jsonand checking thatdatabase.backup_fileis 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.
Two trigger flavors
Auto-pick the newest backup
Create an empty trigger file:
touch /path/to/backup/RESTORE_ON_INSTALL
The boot hook will scan /backup/manifests/ for files matching backup-manifest-*.json (or .yaml) and pick the one with the most recent modification time. Best for the common DR case where you simply want the latest snapshot.
Use a specific backup
Write the path of the manifest you want — either relative to the /backup mount root or an absolute path — into the trigger file:
# Relative path (recommended)
echo "manifests/backup-manifest-backup-20260530-190617-e9be97b3.json" \
> /path/to/backup/RESTORE_ON_INSTALL
# Or absolute path inside the container
echo "/backup/manifests/backup-manifest-backup-20260530-190617-e9be97b3.json" \
> /path/to/backup/RESTORE_ON_INSTALL
Use this when you need to restore 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).
# This is a docker-compose-down-v-proof copy in case anything goes wrong.
SNAPSHOT=~/picpeak-snapshots/$(date +%Y%m%d-%H%M%S)
mkdir -p "$SNAPSHOT" && cp -av /path/to/picpeak/backup/. "$SNAPSHOT/"
# 2. Verify the backup is restorable. database.backup_file must be non-null.
LATEST=$(ls -t /path/to/picpeak/backup/manifests/*.json | head -1)
docker compose exec backend cat "/backup/manifests/$(basename $LATEST)" \
| python3 -c "import json,sys; m=json.load(sys.stdin); \
print('DB included:', bool(m.get('database',{}).get('backup_file')))"
# 3. Drop the trigger file. Two variants — pick one:
# (a) auto-pick newest
touch /path/to/picpeak/backup/RESTORE_ON_INSTALL
# (b) specific manifest
echo "manifests/backup-manifest-backup-20260530-190617-e9be97b3.json" \
> /path/to/picpeak/backup/RESTORE_ON_INSTALL
# 4. 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:
-
The trigger file must exist. No auto-magic — an admin explicitly drops the file to signal intent.
-
The destination database must be empty. If the database contains any events, the install-from-backup hook refuses to run. The fresh-install default admin (auto-created by migration 001) is treated as throwaway and replaced by the backup's admin row, so a single admin user does not block the restore.
-
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.
Override for advanced cases
If you have a populated install you intentionally want to clobber (dev rebuilds, staging refresh, etc.):
# In docker-compose.yml
backend:
environment:
- INSTALL_FROM_BACKUP_FORCE=true
Or via the CLI:
INSTALL_FROM_BACKUP_FORCE=true docker compose up -d backend
With this set, gate #2 is skipped and the restore proceeds even with existing data. Gates #1 (trigger file presence) and #3 (rollback on failure) still apply.
Verifying DR success
After the boot log shows Install-from-backup: restore completed successfully:
# Trigger should be gone (deleted on successful restore)
ls /path/to/picpeak/backup/RESTORE_ON_INSTALL 2>/dev/null \
|| echo "Trigger cleaned up — restore succeeded."
# Inspect the restore_runs row
docker compose exec -T postgres psql -U picpeak -d picpeak_prod -c \
"SELECT id, status, was_successful, was_rollback_attempted FROM restore_runs ORDER BY id DESC LIMIT 1;"
# Confirm data is back
docker compose exec -T postgres psql -U picpeak -d picpeak_prod -c "
SELECT 'admin' AS t, COUNT(*) FROM admin_users
UNION ALL SELECT 'events', COUNT(*) FROM events
UNION ALL SELECT 'invoices', COUNT(*) FROM invoices
UNION ALL SELECT 'app_settings', COUNT(*) FROM app_settings;"
Then open the admin login and use your original pre-disaster credentials.
Backup History detail
Each row in Backup → Backup History expands to show:
- Database — whether the dump was included
- Per-path file counts — one row per
backup_pathsentry that contributed files, with count + total size. e.g.:events/active 142 (3.2 GB) business-docs 17 (4.5 MB) thumbnails 142 (12.4 MB) - Total files + total bytes
- Error message if the run failed
This breakdown reflects Stage B's data-driven walker, so admins can see at a glance which paths contributed how much.
Settings reference
Backup-related settings live in app_settings with setting_type = 'backup' or setting_type = 'restore':
| Setting | Default | Notes |
|---|---|---|
backup_enabled |
true | Master scheduler switch |
backup_destination_type |
'local' |
'local', 's3', or 'rsync' |
backup_destination_path |
'/backup' |
Local destination |
backup_database_inline_dump |
true | Inline DB dump on every run |
backup_include_archived |
false | Gate events/archived |
backup_incremental |
true | Skip unchanged files (checksum-tracked) |
restore_allow_force |
true | Permit Force Restore via the wizard |
restore_require_pre_backup |
true | Take a pre-restore safety snapshot |
restore_verify_checksums |
true | Verify file checksums after restore |
restore_email_on_completion |
true | Notify admin when restore finishes |
restore_retention_days |
30 | How long pre-restore snapshots survive |
Most settings are exposed in the Backup → Configuration tab. Less common ones can be set via SQL.
Troubleshooting
"Run Backup Now" fails with No database backup available
The inline DB dump was disabled AND no recent scheduled dump exists. Either re-enable inline dumps (set backup_database_inline_dump = 'true') or configure a scheduled DB dump that completes within the staleness window (default 26h).
Backup History row says "completed" but shows 0 files
This is the legacy of a pre-2026-05 install where the walker was hard-coded and missed paths. After upgrading, the new walker captures everything per backup_paths. The 0-file row is historical — new backups will count correctly.
Restore wizard shows "No backups found"
The wizard's disk discovery looks in backup_destination_path + its manifests/ subdirectory. If you moved manifests elsewhere or your bind mount points at a different host directory than expected, the discovery won't find them. Check backup_destination_path in the Configuration tab matches reality.
Restore completes but login fails
Caused by the pre-2026-05 dead-pool bug — fixed in the current image. If you're on a stale image and still see this, restart the backend container once:
docker compose restart backend
Then try logging in again.
Install-from-backup: boot log shows no Install-from-backup: lines
Check that the trigger file is actually visible from inside the container:
docker compose exec backend ls -la /backup/RESTORE_ON_INSTALL
docker compose exec backend cat /backup/RESTORE_ON_INSTALL
If ls reports the file but the hook didn't run, the most likely cause is that the trigger pointed at a manifest that doesn't exist. The hook silently returns when the manifest path can't be resolved. Verify the path inside the file matches an actual manifest:
docker compose exec backend ls -la /backup/manifests/
Install-from-backup: restore fails and leaves the trigger file in place
This is the intentional behavior — fix the input, then restart the container to retry. The boot log will surface the underlying error (e.g. corrupt manifest, missing database dump file, validator warnings without the force override).
Common failure modes:
- Backup is files-only (
database.backup_file = nullin the manifest). Pick a different backup or proceed with caution via the admin wizard, knowing you will restore files only. - Manifest path mismatch. The path in the trigger file points at a manifest that doesn't exist. Either fix the path or use the empty-file auto-pick variant.
- Existing data without the force override. Either start from a truly empty install (
docker compose down -v) or setINSTALL_FROM_BACKUP_FORCE=true.
How do I disable install-from-backup entirely?
Don't create a RESTORE_ON_INSTALL file. Without the trigger, the hook is a no-op on every boot. There is no separate "off switch" because the feature is opt-in by design.
See also
- Deployment — Docker, environment variables, volumes
- Admin Settings — Configuration tab walkthrough