Backup & restore
Postgres holds the state you can't recreate. If you self-host, this is the one thing not to skip.
Zug Zug keeps its durable, hard-to-recreate state in Postgres — drafts, audit log, users, table versions, and (in the default mode) the published records and mappings themselves. Backing it up is the one thing you must not skip.
What to back up (in priority order)
- Postgres (
zugzug_appschema) — critical. Drafts and audit history live nowhere else. In the default mode your published records and mappings live here too. Lose this and you lose the workspace. - The server
/datavolume — important if you use webhooks. It holds the cursor key and, when webhooks are on, the AES-256-GCM webhook master key. Losing the master key makes every stored webhook secret unrecoverable. - Your warehouse — usually not your job. It's read-only to Zug Zug. Even in
direct-write mode, the
dim_<x>/map_<x>tables can be re-materialized by re-publishing from Postgres — so Postgres is still the source of truth.
Backing up Postgres
For the Docker Compose stack (Postgres service postgres, user + db zugzug):
docker compose exec -T postgres \
pg_dump -U zugzug -d zugzug --format=custom \
> "zugzug-$(date +%Y%m%d-%H%M%S).dump"For an external / managed Postgres, run pg_dump against DATABASE_URL:
pg_dump "$DATABASE_URL" --format=custom > zugzug-$(date +%Y%m%d).dumpSchedule it
A backup you don't take doesn't exist. Run the dump on a cron (daily is sane for most teams) and keep several days off-box. Match the cadence to how much curation work you're willing to lose — that's your RPO.
Backing up the /data volume
Only needed if you use webhooks. Copy the keys out of the server's data dir (compose
volume serverdata, mounted at /data):
docker compose cp server:/data ./zugzug-data-backupOr supply the keys explicitly via env (ZUGZUG_CURSOR_KEY,
ZUGZUG_WEBHOOK_MASTER_KEY / …_FILE) and store them in your secret manager — then
there's nothing in /data to lose. Either way, keep the webhook master key
somewhere you can recover it: lose it and every stored webhook secret is
unrecoverable.
Restoring
Restore into an empty database on the same Postgres major version, with the
server stopped so nothing writes mid-restore. The custom-format dump above restores
with pg_restore.
For the Docker Compose stack:
# 1. Stop the server (Postgres stays up).
docker compose stop server
# 2. Restore — --clean --if-exists drops and recreates objects first.
docker compose exec -T postgres \
pg_restore -U zugzug -d zugzug --clean --if-exists --no-owner \
< zugzug-20260101-120000.dump
# 3. Bring the server back. Any newer migrations run automatically on boot.
docker compose start serverFor an external / managed Postgres:
pg_restore "$DATABASE_URL" --clean --if-exists --no-owner \
zugzug-20260101.dumpIf you use webhooks, also restore /data so the master key matches the encrypted
secrets in the restored database, then start the rest of the stack:
docker compose cp ./zugzug-data-backup/. server:/data
docker compose up -dVerify: curl -fsS http://localhost:8080/api/health returns {"ok":true,…}, and your
tables and audit history are present in the app.
After a restore
Migrations run on server boot, so a dump from an older release is brought up to the
current schema automatically — restore forward, never into an older binary. In
direct-write mode the dim_<x> / map_<x> tables aren't in the dump; re-publish
once to re-materialize them from the restored Postgres state.
Test your restore
An untested backup is a guess. At least once, restore a dump into a throwaway database, confirm the app comes up against it, and check the data is intact. Do it before you need it, not during an incident.