Skip to content

Backup & Restore

A Compose deployment keeps state in six named Docker volumes, plus your object storage bucket and your configuration files. scripts/backup.sh has a flag for only three of those locations, and just one of the six volumes. The table below covers every location and gives each one a verdict, so nothing is left out silently.

Persistent location Back it up? Script flag Details
postgres_data Yes. This is the critical backup. --db PostgreSQL data at /var/lib/postgresql/data
Object storage Yes, when configured. --storage The bucket configured by S3_ENDPOINT and S3_BUCKET
Configuration Yes. --config .env, certificates, and Compose files. Encrypted with tar and openssl
api_data Yes, manually. None Patch compliance report CSV files at /data/patch-reports
redis_data No. Preserve the volume, but do not back it up and do not restore a snapshot. None Rebuildable Redis state at /data
binaries No. None Agent, viewer, and helper binaries at /data/binaries, mounted read-only. Refilled on every start
caddy_data Usually no. None Caddy’s ACME account key and issued TLS certificates. See the note below
caddy_config No. None Caddy’s autosaved runtime config, rewritten from docker/Caddyfile.prod on start

./scripts/backup.sh --all runs --db, --storage, and --config. It does not touch any of the named volumes.

The api_data volume is mounted at /data on the api service, and on the worker service when you run the worker-split profile. Its only persistent content is patch compliance reports, written as plain CSV files under /data/patch-reports. PostgreSQL stores the path to each file, so if the volume is lost the database keeps report rows whose files no longer exist and downloading one fails.

A report can be run again, but the replacement uses the current patch state. It does not reproduce the historical snapshot captured by the original CSV file.

scripts/backup.sh has no flag for this volume. It shells out to pg_dump, the S3 client and openssl, and reading a Docker volume needs access to the Docker socket, so the volume is handled as a separate command rather than a fourth flag. Copy it out with a throwaway container, writing into the same $BACKUP_DIR the script uses:

Terminal window
docker run --rm -v breeze_api_data:/data -v "${BACKUP_DIR:-/var/backups/breeze}":/backup alpine \
tar czf /backup/api_data_$(date +%Y%m%d).tar.gz -C /data .

Compose prefixes the volume with the project name. The example assumes COMPOSE_PROJECT_NAME=breeze. Confirm the actual name before running the command:

Terminal window
docker volume ls

Restore with the stack stopped. Extracting a tar archive over a volume overwrites the files it contains but does not delete anything else, so any report written after the backup was taken survives the restore and ends up mixed in with the restored files. Clear the directory first if you want the volume to match the archive exactly:

Terminal window
export API_DATA_ARCHIVE="${BACKUP_DIR:-/var/backups/breeze}/api_data_YYYYMMDD.tar.gz"
docker compose down
# Optional: start from an empty directory so the restore is exact.
docker run --rm -v breeze_api_data:/data alpine \
sh -c 'rm -rf /data/patch-reports'
docker run --rm -v breeze_api_data:/data -v "$(dirname "$API_DATA_ARCHIVE")":/backup alpine \
tar xzf "/backup/$(basename "$API_DATA_ARCHIVE")" -C /data
docker compose up -d

Both commands run as root inside the throwaway alpine container, so tar preserves the numeric owner and the API process (uid 1001) can still read the files afterwards.

Custom deployments must set PATCH_REPORT_STORAGE_PATH to a persistent location. Its default, ./data/patch-reports, is relative, so it resolves against the API process’s working directory rather than against the volume. In the published image that working directory is /app/apps/api, putting the reports in the container’s own writable layer, where they are lost the moment the container is replaced. The shipped Compose files set the absolute /data/patch-reports, and that override is what places the files on api_data.

The redis_data volume is mounted at /data on Redis. Redis uses AOF persistence, so the volume survives container restarts. Preserve it across upgrades, and do not run docker compose down -v.

Do not add redis_data to a backup rotation, and do not restore it from a snapshot. PostgreSQL and Redis do not have a consistent snapshot mechanism. Restoring stale Redis state can reintroduce jobs that PostgreSQL records as complete.

Starting Redis empty loses:

  • Queued and in-flight jobs, including work waiting for a retry. The next scheduled tick re-derives work from PostgreSQL, so this delays work instead of causing permanent data loss.
  • Access-token revocation entries. A revoked but unexpired access token can be accepted again for at most 15 minutes for a normal session or 2 hours for a remote-desktop or viewer token.
  • Rate-limit counters.
  • Redis pub/sub messages that were in flight.

Starting Redis empty does not lose recurring schedules. They live in PostgreSQL and are registered again when the API and worker start. It also does not lose refresh-token family revocation, which is durable in PostgreSQL and is preserved by a database backup.

If the access-token window matters, such as after off-boarding an administrator, sign the affected users out again after the stack starts. This writes fresh revocation entries to the new Redis state.

Do not back up the binaries volume. The binaries-init container rebuilds it on every docker compose up -d, and the API mounts it read-only. With the default BINARY_SOURCE=github, the API syncs binaries from GitHub Releases instead of using this volume.

Terminal window
# PostgreSQL client tools
sudo apt-get install postgresql-client-16
# psql is used by the restore verification step
psql --version
# MinIO Client (for --storage)
curl -O https://dl.min.io/client/mc/release/linux-amd64/mc
chmod +x mc && sudo mv mc /usr/local/bin/
# openssl (typically pre-installed)
openssl version

Set before running backup scripts:

Terminal window
# Required for --db
export DATABASE_URL="postgresql://breeze:password@localhost:5432/breeze"
# Required for --config
export BACKUP_ENCRYPTION_KEY="a-strong-passphrase-at-least-32-chars"
# Required for --storage
export S3_ENDPOINT="http://localhost:9000"
export S3_BUCKET="breeze"
export S3_ACCESS_KEY="minioadmin"
export S3_SECRET_KEY="minioadmin"
# Optional
export BACKUP_DIR="/var/backups/breeze" # default: /var/backups/breeze
export BACKUP_RETENTION_DAYS="30" # default: 30
Terminal window
# Back up all script-managed components
./scripts/backup.sh --all
# Database only
./scripts/backup.sh --db
# Object storage only
./scripts/backup.sh --storage
# Configuration only (encrypted)
./scripts/backup.sh --config

Backups are stored in $BACKUP_DIR with timestamps:

/var/backups/breeze/
├── db_20260211_120000.dump
├── storage_20260211_120000/
└── config_20260211_120000.tar.gz.enc

Add a cron job for daily backups:

Terminal window
# Edit crontab
crontab -e
# Daily backup of all script-managed components at 2 AM
0 2 * * * /path/to/breeze/scripts/backup.sh --all >> /var/log/breeze-backup.log 2>&1
Terminal window
# Restore database
./scripts/restore.sh --db /var/backups/breeze/db_20260211_120000.dump
# Restore storage directory
./scripts/restore.sh --storage /var/backups/breeze/storage_20260211_120000
# Restore configuration (prompts for encryption key)
./scripts/restore.sh --config /var/backups/breeze/config_20260211_120000.tar.gz.enc

restore.sh covers the same three components as backup.sh. To restore the api_data volume, use the tar command in The api_data Volume. Do not restore redis_data.

Backups older than BACKUP_RETENTION_DAYS (default: 30) are automatically pruned during each backup run.

For disaster recovery, sync backups to a remote location:

Terminal window
# Sync to S3/R2
aws s3 sync /var/backups/breeze s3://breeze-backups/ --delete
# Sync to remote server
rsync -avz /var/backups/breeze/ backup-server:/backups/breeze/