Zug Zug.
Guides

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)

  1. Postgres (zugzug_app schema) — 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.
  2. The server /data volume — 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.
  3. 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).dump

Schedule 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-backup

Or 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 server

For an external / managed Postgres:

pg_restore "$DATABASE_URL" --clean --if-exists --no-owner \
  zugzug-20260101.dump

If 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 -d

Verify: 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.

On this page