Skip to content

Backup and restore

z4j ships two CLI commands for database snapshots:

  • z4j backup --output PATH - point-in-time snapshot to a single file
  • z4j restore PATH --force - restore from a backup file

Both auto-detect the backend from Z4J_DATABASE_URL:

BackendBackup mechanismRestore mechanism
SQLiteVACUUM INTO (online; brain keeps serving)File copy (brain MUST be stopped)
PostgreSQLpg_dump -Fc -Z6 (online)pg_restore --clean --if-exists

These commands handle the database only. For a full disaster-recovery plan you also need ~/.z4j/secret.env (auto-minted secrets - the only copy of Z4J_SECRET and Z4J_SESSION_SECRET for that install) and ~/.z4j/allowed-hosts (operator-managed). See What else to back up.

Terminal window
z4j backup --output /var/backups/z4j-$(date +%Y-%m-%d).db

z4j keeps serving requests during the backup - VACUUM INTO produces a consistent snapshot via SQLite’s online backup API. The output is a fully self-contained SQLite file (no WAL, no journal needed alongside).

Output:

z4j: backup complete
backend: sqlite
output: /var/backups/z4j-2026-04-24.db
size: 12.34 MiB
z4j: move this file off-host (scp, rclone, S3, ...) for true disaster recovery.

A backup left on the same host as the original is half a backup. Push it off-host immediately:

Terminal window
# rclone to S3
rclone copy /var/backups/z4j-2026-04-24.db s3:my-backups/z4j/
# scp to a backup server
scp /var/backups/z4j-2026-04-24.db backup-host:/srv/z4j-backups/
# Restic / Borg / your existing backup tooling - just give it the file
Terminal window
z4j backup --output /var/backups/z4j-$(date +%Y-%m-%d).dump

Requires pg_dump on the operator’s PATH (apt install postgresql-client on Debian/Ubuntu; brew install libpq && brew link --force libpq on macOS).

Uses pg_dump -Fc -Z6 --no-owner --no-acl:

  • -Fc - custom format (compressible, selective-restore-able)
  • -Z6 - gzip compression level 6
  • --no-owner --no-acl - portable across environments (don’t bake in roles)

z4j keeps serving requests; pg_dump is a normal connection. Output looks the same as the SQLite case.

Stop z4j first. SQLite needs an exclusive write lock to be replaced safely:

Terminal window
sudo systemctl stop z4j
z4j restore /var/backups/z4j-2026-04-24.db --force
sudo systemctl start z4j

--force is required - the CLI refuses without it as a safety against accidentally restoring on top of a live install. The existing DB (if any) is moved to <dbpath>.pre-restore-bak so you can roll back manually:

Terminal window
# If something is wrong after restore:
sudo systemctl stop z4j
mv ~/.z4j/z4j.db ~/.z4j/z4j.db.bad
mv ~/.z4j/z4j.db.pre-restore-bak ~/.z4j/z4j.db
sudo systemctl start z4j

After restart, verify with z4j check && z4j status.

Same shape:

Terminal window
sudo systemctl stop z4j
z4j restore /var/backups/z4j-2026-04-24.dump --force
sudo systemctl start z4j

Uses pg_restore --clean --if-exists --no-owner --no-acl so the restore is idempotent against a partially-populated target DB.

For Postgres you can also restore to a different target (point Z4J_DATABASE_URL at a fresh DB and call z4j restore). Useful for staging-from-prod and disaster-recovery dry runs.

Terminal window
z4j check # config + DB connectivity + alembic at head
z4j status # row counts: users, projects, agents, tasks, audit
z4j audit verify # walk the HMAC audit chain end-to-end

If audit verify fails after a restore, you have an integrity issue - usually the source backup was taken on a different brain instance with a different Z4J_SECRET. The audit chain is signed with the master HMAC; restoring rows from one install into another with a different secret breaks the chain.

The CLI is designed for cron / systemd timer usage. Sample systemd timer (Debian/Ubuntu):

/etc/systemd/system/z4j-backup.service
[Unit]
Description=z4j daily backup
After=z4j.service
[Service]
Type=oneshot
User=z4j
Environment=Z4J_DATABASE_URL=sqlite+aiosqlite:////srv/z4j/.z4j/z4j.db
ExecStart=/srv/venv/bin/z4j backup --output /var/backups/z4j-%Y-%m-%d.db
ExecStartPost=/usr/bin/find /var/backups -name 'z4j-*.db' -mtime +14 -delete
/etc/systemd/system/z4j-backup.timer
[Unit]
Description=Run z4j backup daily
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target
Terminal window
sudo systemctl enable --now z4j-backup.timer
sudo systemctl list-timers z4j-backup.timer

For Docker, run the backup command via docker exec from the host’s cron:

Terminal window
0 3 * * * docker exec z4j z4j backup --output /backups/z4j-$(date +\%Y-\%m-\%d).db && find /backups -name 'z4j-*.db' -mtime +14 -delete

z4j DB is the bulk of your state, but a complete restore needs:

PathWhat it carriesHow often it changes
DB (SQLite file or Postgres)All operational state - users, agents, tasks, audit chain, schedulesContinuous
~/.z4j/secret.env (SQLite/pip)Auto-minted Z4J_SECRET + Z4J_SESSION_SECRET - the only copy unless you set them via envOnce per install (immutable)
~/.z4j/allowed-hostsOperator-managed Host allow-listWhen you add/remove hosts
Agent tokens (in your apps)Bearer tokens minted from the dashboardWhen you mint/rotate
.env / Docker compose.ymlWhatever you set Z4J_* env vars toWhen you change config

If you set Z4J_SECRET + Z4J_SESSION_SECRET explicitly via env vars (recommended for production), ~/.z4j/secret.env doesn’t exist and you only need the env-vars stored in your secret manager. The CLI’s z4j backup covers the DB; you’re responsible for the env / secret store.