6.8 KiB
Backup CLI Runbook
Operator guide for @hivegrid/cli backup:*. The CLI ships in packages/cli and
is callable as hivegrid-cli backup <subcommand> once the workspace is
installed.
Quick reference
| Command | Purpose |
|---|---|
backup:create |
Dump Postgres (+ Storage, + ClickHouse) and upload to S3. |
backup:list |
List backups in the bucket with classification + encryption. |
backup:rotate |
Apply daily/weekly/monthly retention. |
backup:restore |
Download, decrypt, extract; pg_restore against a target DSN. |
backup:verify |
Restore into a verify_<uuid> schema and run sanity checks. |
1. Bucket + IAM setup
The backup bucket is intentionally separate from the application storage bucket so a compromised app credential cannot pivot to the backups.
# MinIO example (staging)
mc mb staging-minio/hivegrid-backups
mc anonymous set none staging-minio/hivegrid-backups
# AWS S3 example
aws s3api create-bucket --bucket hivegrid-backups \
--create-bucket-configuration LocationConstraint=us-east-1
aws s3api put-bucket-encryption --bucket hivegrid-backups \
--server-side-encryption-configuration \
'{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
aws s3api put-public-access-block --bucket hivegrid-backups \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
Create a dedicated IAM user / access key pair with PutObject,
GetObject, ListBucket, DeleteObject on the backup bucket only. Use those
values for BACKUP_S3_ACCESS_KEY_ID + BACKUP_S3_SECRET_ACCESS_KEY (do not
re-use the app STORAGE_* credentials).
For long-term retention, enable Object Lock with COMPLIANCE mode and / or MFA delete so a rotated IAM key cannot purge history.
2. Encryption keys
The default encryption method is age.
# Generate a key pair on a trusted workstation
age-keygen -o ~/.age-hivegrid-backup.key
# Output:
# Public key: age1example...
# Identity (private key) is at ~/.age-hivegrid-backup.key
# Set the public key in the deployment env
echo 'BACKUP_AGE_PUBLIC_KEY=age1example...' >> /etc/hivegrid/backup.env
Store the identity file (private key) somewhere the running app does not have
access to — a password manager, a tamper-evident envelope, a hardware token.
The public key alone is what the create flow needs; the private key only
appears in restore / verify environments via BACKUP_AGE_IDENTITY_FILE.
If the operator already has a GPG fleet, set BACKUP_ENCRYPTION=gpg and
BACKUP_GPG_RECIPIENT=<key id> instead.
Warning: Lose the identity file and the backups are unrecoverable. The CLI cannot bypass age encryption.
3. Environment template
# Backup bucket
BACKUP_S3_ENDPOINT=https://s3.your-provider.com
BACKUP_S3_REGION=us-east-1
BACKUP_S3_BUCKET=hivegrid-backups
BACKUP_S3_ACCESS_KEY_ID=AKIA...
BACKUP_S3_SECRET_ACCESS_KEY=...
BACKUP_S3_FORCE_PATH_STYLE=true # MinIO/R2; AWS uses host-style
# Encryption (age default)
BACKUP_ENCRYPTION=age
BACKUP_AGE_PUBLIC_KEY=age1example...
# Restore-side only:
# BACKUP_AGE_IDENTITY_FILE=/secrets/age-backup.key
# Retention
BACKUP_RETENTION_DAILY=7
BACKUP_RETENTION_WEEKLY=4
BACKUP_RETENTION_MONTHLY=12
# Optional
BACKUP_INCLUDE_CLICKHOUSE=false
Postgres source is picked up from DIRECT_URL (preferred — bypasses the
PgBouncer pool so prepared statements work) or DATABASE_URL as a fallback.
4. Daily cron
# /etc/cron.d/hivegrid-backup
SHELL=/bin/sh
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=ops@example.com
0 3 * * * deploy /opt/hivegrid/scripts/cron/backup-daily.sh >> /var/log/hivegrid-backup.log 2>&1
Example wrapper:
#!/usr/bin/env bash
set -euo pipefail
cd /opt/hivegrid
node ./packages/cli/bin/hivegrid-cli.mjs backup create
node ./packages/cli/bin/hivegrid-cli.mjs backup rotate
For docker-compose deployments:
backup-cron:
image: alpine:latest
volumes:
- ./scripts/cron/backup-daily.sh:/script.sh:ro
command: ["sh", "-c", "echo '0 3 * * * /script.sh' | crontab - && crond -f"]
depends_on: [postgres]
env_file: .env
Wire failures into your alerting channel (Sentry, Slack, PagerDuty) — an untested backup is no backup, but an unalerted failed backup is the same risk.
5. Disaster recovery drill
Run at least monthly:
# 1. Pick yesterday's backup
hivegrid-cli backup list
# 2. Verify in-place (creates verify_<uuid>, drops it after)
hivegrid-cli backup verify backup-2026-05-17T030000Z-aabbccdd.tar.gz.age
# 3. End-to-end DR drill (restore into a scratch host)
DATABASE_URL=postgres://dr-test/restored \
BACKUP_AGE_IDENTITY_FILE=/secrets/age-backup.key \
hivegrid-cli backup restore backup-2026-05-17T030000Z-aabbccdd.tar.gz.age --yes
Document the drill outcome in the incident channel or your internal ops ledger. If the drill becomes a repository-maintained runbook, add the Markdown file first and link it from this section.
6. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
pg_dump exited with code 1 |
DIRECT_URL missing or pgbouncer in the connection. |
Set DIRECT_URL to the port-5432 DSN, not the pooler. |
BACKUP_AGE_PUBLIC_KEY is required |
BACKUP_ENCRYPTION=age but no public key in env. |
Generate via age-keygen and set the value. |
age: error: failed to read header: parsing recipient |
Identity file does not match the public key. | Decrypt with the matching identity; never delete the key store. |
clickhouse-client: command not found |
BACKUP_INCLUDE_CLICKHOUSE=true but no client on PATH. |
Either install the client or set the flag to false. |
backup:verify reports "no tables present" |
Restore ran against an empty DSN. | Confirm DATABASE_URL points to a non-empty DB and that pg_restore succeeded above. |
7. Sizing
Backup size grows ≈ DB dump size + Σ(storage object sizes) (no
compression on objects, only on the dump). Track via Prometheus
s3_object_size_bytes and alert when daily delta is below historic minimum
(a silent-fail symptom).