Wiki
Backup & Recovery
How QuoteNode handles database and file backups — age-encrypted archives, manifest-driven restore, recovery kits, and runtime profiles.
Backup & Recovery
QuoteNode includes a built-in backup system that protects your commercial data against hardware failures, accidental deletions, and operational incidents.
What Is Backed Up
Each backup creates an archive containing:
- Database dump — a full PostgreSQL dump in custom format (
pg_dump --format=custom), including all tables, sequences, and constraints. - File storage — all uploaded files (product images, company logos) and generated PDFs, archived as
files.tar.gzwith relative paths. - Backup manifest — a
backup-manifest.jsonfile containing source version, encryption mode, database payload mode, checksums, and restore compatibility metadata. - Integrity checksums — a
checksums.sha256file containing SHA-256 hashes of all archive components.
Archive Encryption
New encrypted backups use age recipient encryption (AGE_RECIPIENT). This is the only supported encryption model for new archives.
- The backup process uses only a public age recipient (
age1...) — no private key is needed on the server to create backups. - Encrypted archives have a
.tar.ageextension. - The encrypted archive SHA-256 checksum is stored alongside the archive for operator-side verification.
- Decryption requires the matching private age identity, which is held exclusively in the operator’s recovery kit.
- The raw private age identity is never stored in the database, logs, DTOs, or process arguments after setup confirmation.
Unencrypted local backups
Local unencrypted backups (.tar) are allowed only when all of the following are true:
BACKUP_ARCHIVE_ENCRYPTION_MODE=NONEBACKUP_ALLOW_UNENCRYPTED_LOCAL_BACKUP=true- Destination is local storage
BACKUP_DATABASE_PAYLOAD_MODE=APP_ENCRYPTED- The administrator has explicitly acknowledged the risk in the UI
Unencrypted archives are blocked for remote storage and for PII-decrypted payloads.
Configuration (.env). The age recipient is generated by the Recovery Kit (Admin → Security → Backup) and stored in the instance — you do not put a key in .env. Set only the two mode variables:
BACKUP_ENABLED=true
BACKUP_ARCHIVE_ENCRYPTION_MODE=AGE_RECIPIENT
BACKUP_DATABASE_PAYLOAD_MODE=APP_ENCRYPTED
# Alternative — encrypted archive + decrypted PII (cross-instance portability):
# BACKUP_DATABASE_PAYLOAD_MODE=PII_DECRYPTED
BACKUP_ALLOW_UNENCRYPTED_LOCAL_BACKUP=false
To disable archive encryption (local only, not recommended — PII still stays encrypted in the payload):
BACKUP_ARCHIVE_ENCRYPTION_MODE=NONE
BACKUP_ALLOW_UNENCRYPTED_LOCAL_BACKUP=true
With
AGE_RECIPIENT, if no recipient has been generated yet the backup fails loudly rather than writing an unprotected archive.NONE+PII_DECRYPTEDis rejected.
Database Payload Modes
| Mode | Description |
|---|---|
APP_ENCRYPTED | Default. PII fields remain encrypted with the application DB_ENCRYPTION_KEY. Restore requires the same encryption key or a matching key fingerprint. |
PII_DECRYPTED | PII fields are decrypted into a temporary database before archiving. User password hashes remain as-is. Requires AGE_RECIPIENT archive encryption. Useful for cross-instance portability. |
When restoring a PII_DECRYPTED backup into a target with ENCRYPT_PII=true, the restore process automatically re-encrypts PII with the target’s DB_ENCRYPTION_KEY before normal application startup.
Recovery Kit
During setup, the administrator generates (or imports) an age recipient for encrypted backups. The recovery kit is a ZIP file containing:
quotenode-age-identity.txt— the private age identity (returned only once)quotenode-age-recipient.txt— the public recipient and fingerprintquotenode-backup-verify.sh— local verification script (Linux/macOS)quotenode-backup-verify.ps1— local verification script (Windows)README.md— verification and restore commands
Critical: The private age identity is returned only once during kit generation. If lost, encrypted archives cannot be decrypted. Store the recovery kit in a secure offline location.
The administrator must confirm that the kit was saved and tested before the setup wizard marks backup as production-ready.
Local Backup Verification
Downloaded backups can be verified outside the running application using the recovery kit scripts:
scripts/quotenode-backup-verify.sh \
--archive /path/backup.tar.age \
--identity /path/quotenode-age-identity.txt \
--expected-sha256 <sha256-from-ui-or-sidecar>
The verifier:
- Checks the encrypted archive SHA-256 against the expected value or
.sha256sidecar. - Decrypts with
age -dinto a private temp directory. - Validates tar entries (rejects absolute paths,
.., symlinks, device files). - Validates
backup-manifest.jsonexists and is a supported version. - Runs
sha256sum -c checksums.sha256. - Runs
pg_restore --list db.dumpwhenpg_restoreis available. - Removes decrypted temp data by default (use
--keep-decryptedto retain).
Online Backup (Default)
Online backups run while the application is serving requests. There is no downtime.
pg_dumpin custom format creates a transactionally consistent snapshot without locking tables or blocking queries.- File storage is archived in parallel — uploaded files and PDFs are immutable after creation, so the archive is always consistent.
- PostgreSQL does not need to stop for a consistent database dump.
How Backups Run: Choosing a Topology
There are two ways to run scheduled backups continuously. Both produce identical archives — they differ only in where the backup job executes.
| Topology | When to use | Containers |
|---|---|---|
| Dedicated worker (default) | Production. Backups run in a separate backup-worker container, so they never compete with web traffic for memory or CPU. | web backend + backup-worker |
| In-process | Laptop / evaluation / small single-host installs. The web backend also runs backups itself — one less container to operate. | web backend only |
The backup job itself is the same shell pipeline (pg_dump → archive → encrypt) in both cases, run outside the JVM heap, so its memory cost is modest.
How to select a topology
Two environment variables work together:
JOBS_MODEdecides which container actually runs the scheduled backup job.BACKUP_RUNTIME_PROFILEis a label recorded in backup logs and manifests so you can see, after the fact, how each backup was produced.
| Topology | Web backend | Backup worker |
|---|---|---|
| Dedicated worker (default) | JOBS_MODE=web + BACKUP_RUNTIME_PROFILE=WORKER | JOBS_MODE=backup-only container |
| In-process | JOBS_MODE=all + BACKUP_RUNTIME_PROFILE=WEB_MAINTENANCE | (none) |
The included production Compose file uses the dedicated worker topology. The laptop/evaluation guides use in-process (
JOBS_MODE=all+BACKUP_RUNTIME_PROFILE=WEB_MAINTENANCE) so you only run one backend container.
Runtime profile reference
BACKUP_RUNTIME_PROFILE accepts four values. The first two are for the continuous topologies above; the last two are for one-off, operator-driven backups.
| Profile | Description |
|---|---|
WORKER | Default. A permanent backup-worker container runs scheduled backups via cron. |
WEB_MAINTENANCE | The web backend performs scheduled backups directly. No extra worker container needed. |
ONE_SHOT | An external cron or operator runs scripts/backup-one-shot.sh and exits. No permanent worker. |
OFFLINE_MAINTENANCE | Automated script announces downtime, stops frontend/backend/backup-worker, keeps PostgreSQL running, runs backup, restarts services, and verifies health. |
One-shot backup
COMPOSE_PROJECT_NAME=quotenode COMPOSE_ENV_FILE=infra/.env.prod \
BACKUP_ARCHIVE_ENCRYPTION_MODE=AGE_RECIPIENT \
BACKUP_AGE_RECIPIENT=age1... \
bash scripts/backup-one-shot.sh
Offline maintenance
COMPOSE_PROJECT_NAME=quotenode COMPOSE_ENV_FILE=infra/.env.prod \
BACKUP_OFFLINE_NOTICE_SECONDS=300 \
bash scripts/backup-offline-maintenance.sh
The offline maintenance script:
- Announces scheduled maintenance (configurable grace period).
- Optionally enables a static maintenance page while services are down.
- Stops frontend, backend, and backup-worker (keeps PostgreSQL running).
- Runs a one-shot backup.
- Restarts all services.
- Verifies backend health.
- Writes a host-level JSONL maintenance journal.
If the script fails at any point, a safety trap restarts services automatically.
Use --dry-run to preview the plan without executing:
bash scripts/backup-offline-maintenance.sh --dry-run
Scheduling
- Default schedule: 2:00 AM daily (
0 0 2 * * *, configurable viaBACKUP_CRON) - Enable/disable:
BACKUP_ENABLED=true|false - Manual trigger: Administrators can trigger an immediate backup from the admin panel or via API (
POST /api/v1/admin/backup/trigger). The trigger returns immediately (HTTP 202) while the backup runs asynchronously. - Concurrency protection: A database-backed lock ensures only one backup runs at a time across all workers and manual triggers.
For ONE_SHOT and OFFLINE_MAINTENANCE profiles, the internal scheduler is disabled — backups are managed externally.
Storage Options
Local storage (default)
Backups are stored in BACKUP_LOCAL_DIR (default: /app/data/backups), mapped to a Docker volume.
For production, local backups should be supplemented with remote copies.
Remote storage via rclone
If BACKUP_RCLONE_REMOTE is configured, backups are automatically uploaded to a remote destination after local creation. rclone supports 70+ cloud storage providers including S3-compatible, Google Cloud Storage, Azure Blob, SFTP, and WebDAV.
Downloading Backups
Backup archive downloads require step-up authorization:
- Navigate to Settings > Backup.
- Click Download on the backup entry.
- Enter your current password (and TOTP code if MFA is enabled).
- The system issues a short-lived, one-time download grant (default: 120 seconds).
- The archive downloads automatically.
The grant token is stored only as a SHA-256 hash. Grant creation and download are audited and other administrators are notified.
Note: Direct download without a grant returns 403. Backups on remote storage cannot be downloaded through the admin panel.
Metadata & Audit Trail
Every backup is tracked in the backup_logs table with:
- Status (RUNNING, SUCCESS, FAILED)
- Initiated by (SCHEDULER or ADMIN)
- Start/completion time
- Size (bytes) and SHA-256 checksum
- Destination (local path or remote URL)
- Manifest version and source app version
- Archive encryption mode and fingerprint
- Database payload mode
- Runtime profile
- Verification status (NOT_RUN, PASSED, FAILED, SKIPPED_TIMEOUT)
- Restore test status and restore compatibility status
Restoration
Using scripts/restore.sh
The canonical restore script handles the full lifecycle:
COMPOSE_PROJECT_NAME=quotenode \
COMPOSE_ENV_FILE=infra/.env.prod \
RESTORE_AGE_IDENTITY_FILE=/path/to/quotenode-age-identity.txt \
[email protected] \
RESTORE_ADMIN_PASSWORD_FILE=/path/to/password \
bash scripts/restore.sh /path/to/backup.tar.age
The script:
- Extracts and validates the archive (manifest, checksums, tar entry safety).
- Runs preflight checks (disk capacity, PostgreSQL version, DB connectivity).
- Creates a target safety backup before any destructive operation.
- Writes a host-level restore journal (JSONL, survives DB replacement).
- Restores the database.
- Stages files into a temp directory and swaps them into place.
- Preserves target identity (instance ID, backup encryption metadata, license state).
- Clears sessions, public offer links, and notification tokens.
- Resets or force-changes admin access.
- Re-encrypts PII if the backup is
PII_DECRYPTEDand the target hasENCRYPT_PII=true.
Cross-instance restore
When restoring a backup from a different installation, the default mode is PRESERVE_TARGET_IDENTITY:
- Target instance ID, backup age recipient, recovery-kit metadata, and license state are preserved.
- Sessions, public offer links, and notification tokens are cleared.
- Admin password is reset/force-changed.
- Business data is fully replaced (no merge).
Restore compatibility
The restore preflight validates:
- Older backup into newer app: allowed (compatible).
- Newer backup into older app: blocked.
- Missing manifest: blocked.
- Wrong encryption key fingerprint for
APP_ENCRYPTEDpayload: blocked before destructive work. PII_DECRYPTEDwithout archive encryption: blocked.
Admin access guarantee
Restore guarantees admin access through one of:
RESTORE_ADMIN_PASSWORD_FILE— operator-supplied password (set withforcePasswordChange=true)RESTORE_ADMIN_PASSWORD— for local/test use only- Auto-generated one-time reset token — printed to console and host-level journal
Dry run
RESTORE_DRY_RUN=true bash scripts/restore.sh /path/to/backup.tar.age
Validates the archive, checks checksums and manifest, and reports the restore plan without modifying anything.
Disaster Recovery — Rebuilding from Scratch
Your server is gone. You have a backup archive (.tar.age or .tar) and a new server with Docker. Here is how to restore.
Step 1 — Prepare the new server
mkdir quotenode && cd quotenode
Set up docker-compose.yml and .env as described in the Installation Guide.
Critical for APP_ENCRYPTED backups: Use the same
DB_ENCRYPTION_KEYas your original installation. Without it, encrypted PII will be permanently unreadable.
Critical for encrypted archives: You need the recovery kit private identity file (
quotenode-age-identity.txt) to decrypt the archive.
Step 2 — Copy the backup and restore script
scp backup.tar.age user@new-server:~/quotenode/
scp scripts/restore.sh user@new-server:~/quotenode/
Step 3 — Run the restore
RESTORE_AGE_IDENTITY_FILE=./quotenode-age-identity.txt \
[email protected] \
RESTORE_ADMIN_PASSWORD_FILE=./admin-password \
./restore.sh --fresh-install backup.tar.age
Step 4 — Verify
Open your domain and log in. All customers, offers, products, and settings should be exactly as they were at backup time.
Backup Retention
- Local:
BACKUP_RETENTION_DAILY(default: 7) most recent successful backups are kept. - Remote: configure lifecycle policies on your storage provider.
- PII-decrypted backups should have stricter retention due to their sensitive nature.
Backup Timeout
Each backup has a configurable timeout (default: 30 minutes via BACKUP_TIMEOUT_MINUTES). If exceeded, the backup is terminated and recorded as FAILED.
Recommended Strategy
- Enable daily automatic backups with
AGE_RECIPIENTencryption and remote storage. - Generate a recovery kit during setup and store it in a secure offline location.
- Verify your recovery kit against a real backup using the included verifier scripts.
- Test restore procedures periodically — a backup that has never been tested is not a backup.
- Download a backup at least monthly to an offline location (requires step-up authorization).
- Monitor backup status in the admin panel — warnings appear for stale, failed, or unencrypted backups.
- Keep at least 7 days of backup history (the default).