Skip to content
Q
QuoteNode

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:

  1. Database dump — a full PostgreSQL dump in custom format (pg_dump --format=custom), including all tables, sequences, and constraints.
  2. File storage — all uploaded files (product images, company logos) and generated PDFs, archived as files.tar.gz with relative paths.
  3. Backup manifest — a backup-manifest.json file containing source version, encryption mode, database payload mode, checksums, and restore compatibility metadata.
  4. Integrity checksums — a checksums.sha256 file 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.age extension.
  • 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=NONE
  • BACKUP_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_DECRYPTED is rejected.

Database Payload Modes

ModeDescription
APP_ENCRYPTEDDefault. PII fields remain encrypted with the application DB_ENCRYPTION_KEY. Restore requires the same encryption key or a matching key fingerprint.
PII_DECRYPTEDPII 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 fingerprint
  • quotenode-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:

  1. Checks the encrypted archive SHA-256 against the expected value or .sha256 sidecar.
  2. Decrypts with age -d into a private temp directory.
  3. Validates tar entries (rejects absolute paths, .., symlinks, device files).
  4. Validates backup-manifest.json exists and is a supported version.
  5. Runs sha256sum -c checksums.sha256.
  6. Runs pg_restore --list db.dump when pg_restore is available.
  7. Removes decrypted temp data by default (use --keep-decrypted to retain).

Online Backup (Default)

Online backups run while the application is serving requests. There is no downtime.

  • pg_dump in 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.

TopologyWhen to useContainers
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-processLaptop / 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_MODE decides which container actually runs the scheduled backup job.
  • BACKUP_RUNTIME_PROFILE is a label recorded in backup logs and manifests so you can see, after the fact, how each backup was produced.
TopologyWeb backendBackup worker
Dedicated worker (default)JOBS_MODE=web + BACKUP_RUNTIME_PROFILE=WORKERJOBS_MODE=backup-only container
In-processJOBS_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.

ProfileDescription
WORKERDefault. A permanent backup-worker container runs scheduled backups via cron.
WEB_MAINTENANCEThe web backend performs scheduled backups directly. No extra worker container needed.
ONE_SHOTAn external cron or operator runs scripts/backup-one-shot.sh and exits. No permanent worker.
OFFLINE_MAINTENANCEAutomated 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:

  1. Announces scheduled maintenance (configurable grace period).
  2. Optionally enables a static maintenance page while services are down.
  3. Stops frontend, backend, and backup-worker (keeps PostgreSQL running).
  4. Runs a one-shot backup.
  5. Restarts all services.
  6. Verifies backend health.
  7. 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 via BACKUP_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:

  1. Navigate to Settings > Backup.
  2. Click Download on the backup entry.
  3. Enter your current password (and TOTP code if MFA is enabled).
  4. The system issues a short-lived, one-time download grant (default: 120 seconds).
  5. 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:

  1. Extracts and validates the archive (manifest, checksums, tar entry safety).
  2. Runs preflight checks (disk capacity, PostgreSQL version, DB connectivity).
  3. Creates a target safety backup before any destructive operation.
  4. Writes a host-level restore journal (JSONL, survives DB replacement).
  5. Restores the database.
  6. Stages files into a temp directory and swaps them into place.
  7. Preserves target identity (instance ID, backup encryption metadata, license state).
  8. Clears sessions, public offer links, and notification tokens.
  9. Resets or force-changes admin access.
  10. Re-encrypts PII if the backup is PII_DECRYPTED and the target has ENCRYPT_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_ENCRYPTED payload: blocked before destructive work.
  • PII_DECRYPTED without archive encryption: blocked.

Admin access guarantee

Restore guarantees admin access through one of:

  • RESTORE_ADMIN_PASSWORD_FILE — operator-supplied password (set with forcePasswordChange=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_KEY as 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.

  1. Enable daily automatic backups with AGE_RECIPIENT encryption and remote storage.
  2. Generate a recovery kit during setup and store it in a secure offline location.
  3. Verify your recovery kit against a real backup using the included verifier scripts.
  4. Test restore procedures periodically — a backup that has never been tested is not a backup.
  5. Download a backup at least monthly to an offline location (requires step-up authorization).
  6. Monitor backup status in the admin panel — warnings appear for stale, failed, or unencrypted backups.
  7. Keep at least 7 days of backup history (the default).

Last reviewed: Recently