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:
| File | Format | Purpose |
|---|---|---|
pg_dump.dump | Postgres custom format (compressed binary) | All schemas the postgres role owns — public, auth, storage, _realtime, _ccd_migrations. |
storage.tar | tar | Full Supabase Storage volume contents (uploaded files). |
manifest.json | JSON | Format 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:
- Verifies SHA-256 against the manifest. Mismatch → die with the expected vs actual checksums.
- Stops the app services (
web,api-gateway,ai-services,worker). Leaves the data plane running — Postgres is the restore target. - Pipes the dump into
pg_restore --clean --if-exists --no-owner --no-privileges. - Wipes the storage volume (
find . -mindepth 1 -delete) and re-extracts. - 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.envseparately 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.