Backup + restore

The bundle ships ./ccd, a small POSIX-sh operator CLI that runs inside your install directory. It wraps pg_dump + the Supabase Storage volume into single, verifiable tarballs.

./ccd backup create [--out FILE]      # create a backup
./ccd backup list                     # list existing backups
./ccd backup restore FILE [--yes]     # restore from a backup

What's inside a backup

Each tarball contains three files:

FileFormatPurpose
pg_dump.dumpPostgres custom format (compressed binary)All schemas the postgres role owns — public, auth, storage, _realtime, _ccd_migrations.
storage.tartarFull Supabase Storage volume contents (uploaded files).
manifest.jsonJSONFormat version, CCD version, UTC timestamp, byte size + SHA-256 of each component.

Restore verifies the SHA-256 against the manifest before applying — a corrupted backup gets caught explicitly, not in the middle of a pg_restore.

Creating a backup

# Default — writes to ./backups/ccd-backup-<UTC-timestamp>.tar.gz
./ccd backup create

# Pick a custom path (e.g. mounted NAS share)
./ccd backup create --out /mnt/nas/ccd-$(date +%Y%m%d).tar.gz

Backup runs against the live stack — no downtime. pg_dump runs inside the Postgres container so the dump uses the same version as the cluster.

Offsite backups

There's no built-in offsite story by design — backups are self-contained tarballs, so any tool that ships files works. The canonical pattern: nightly cron + rclone/rsync to a remote bucket.

# /etc/cron.d/ccd-backup — root, daily at 02:00 local
0 2 * * * cd /home/ccd-operator/ccd && ./ccd backup create && \
          rclone copy ./backups/ remote:ccd-prod-backups/ --max-age 7d

Retention is whatever your remote tool does. Locally, the ./backups/ directory grows — periodically prune.

Listing backups

./ccd backup list
FILE                                                  SIZE  MODIFIED
ccd-backup-2026-06-05-020000.tar.gz                  87M   2026-06-05 02:00:14
ccd-backup-2026-06-06-020000.tar.gz                  89M   2026-06-06 02:00:11

Restoring

⚠️ Restore is destructive. It drops every restored schema (pg_restore --clean --if-exists) and wipes the storage volume before extracting. Take a fresh backup first if you're unsure.

./ccd backup restore ./backups/ccd-backup-2026-06-06-020000.tar.gz

The CLI:

  1. Verifies SHA-256 against the manifest. Mismatch → die with the expected vs actual checksums.
  2. Stops the app services (web, api-gateway, ai-services, worker). Leaves the data plane running — Postgres is the restore target.
  3. Pipes the dump into pg_restore --clean --if-exists --no-owner --no-privileges.
  4. Wipes the storage volume (find . -mindepth 1 -delete) and re-extracts.
  5. Restarts the app services.

Typed RESTORE confirmation by default. Pass --yes for scripted use (CI rollback, automated DR drills, etc.).

What's NOT in a backup

  • Ollama models — they're easy to re-pull (docker compose up -d ollama-preload). Backing up 5 GB of model weights every night is wasteful when the source-of-truth lives in Ollama's CDN.
  • .env — contains secrets, shouldn't transit through backup pipelines. Back up your .env separately using whatever secret- management story you have (Vault, 1Password, encrypted file).
  • Container logs — emit to your observability stack instead (see Observability).

Disaster recovery drill

The point of backups is the restore. Run a drill quarterly:

# 1. Take a fresh backup
./ccd backup create

# 2. Snapshot the latest tarball name
LATEST=$(ls -t backups/*.tar.gz | head -1)

# 3. Nuke the install (don't run on production — use a staging copy!)
docker compose down -v

# 4. Re-install + restore
./install.sh    # fresh install
./ccd backup restore "$LATEST" --yes

# 5. Verify
curl http://localhost:3000/api/health
docker compose exec db psql -U postgres -c 'SELECT count(*) FROM auth.users'

If the drill produces a working stack, your backups are credible.