Files
HealthLog/docs/ops/backup-restore.md
T
Marc-André Bombeck ce3b4c70c8 chore(ops): signed images, a secret gate before commit, and a monthly restore drill
Published manifests are now cosign-signed keyless with a documented
digest-pinning path, a lefthook pre-commit runs gitleaks over staged
changes, responses opt into a credentialless embedder policy, the
deploy webhook can require a fresh timestamp, and a monthly drill
decrypts and parses the newest offhost backup so a broken key or format
surfaces before an emergency does.
2026-06-11 00:10:01 +02:00

4.4 KiB

Off-host backup & restore

HealthLog ships with an optional daily off-host backup that ships every user's JSON dump, encrypted with AES-256-GCM under a SEPARATE key (BACKUP_ENCRYPTION_KEY), to any S3-compatible bucket — Cloudflare R2, AWS S3, Backblaze B2, MinIO, etc.

The backup runs at 02:30 Europe/Berlin every day from the worker container (queue data-backup-offhost). Object key layout:

<bucket>/YYYY-MM-DD/user-<userId>.json.enc

Wire format (binary)

magic   = "HLBK"           (4 bytes, ASCII)
version = 0x01             (1 byte)
iv      = 12 random bytes  (AES-GCM nonce)
authTag = 16 bytes         (AES-GCM tag)
ciphertext = N bytes       (AES-256-GCM, key = BACKUP_ENCRYPTION_KEY)
plaintext  = JSON dump (UTF-8)

Required env vars

Var Required Notes
BACKUP_ENCRYPTION_KEY yes 64 hex chars or 32-byte base64. Different from ENCRYPTION_KEY.
BACKUP_S3_ENDPOINT yes e.g. https://<account>.r2.cloudflarestorage.com
BACKUP_S3_BUCKET yes
BACKUP_S3_ACCESS_KEY yes
BACKUP_S3_SECRET_KEY yes
BACKUP_S3_REGION no defaults to auto (Cloudflare R2)
BACKUP_RETENTION_DAYS no defaults to 30

The worker prunes objects older than BACKUP_RETENTION_DAYS, but the storage provider's lifecycle rule is the canonical safety net:

Filter: "" (all objects)
Action: Expire after 30 days

For Cloudflare R2 add this from the bucket's Settings → Lifecycle.

Smoke test

After deploying, hit POST /api/admin/backup/test (admin-only). It performs a 1-byte PUT + GET round-trip and returns:

{
  "endpoint": "https://...r2.cloudflarestorage.com",
  "bucket": "healthlog-backups",
  "region": "auto",
  "putLatencyMs": 142,
  "getLatencyMs": 38,
  "ok": true
}

The credentials are never returned.

Restore

Pick a key (e.g. 2026-05-08/user-clx123.json.enc) from the bucket and run:

BACKUP_S3_ENDPOINT=https://...r2.cloudflarestorage.com \
BACKUP_S3_BUCKET=healthlog-backups                     \
BACKUP_S3_ACCESS_KEY=...                               \
BACKUP_S3_SECRET_KEY=...                               \
BACKUP_S3_REGION=auto                                  \
BACKUP_ENCRYPTION_KEY=$(openssl rand -hex 32)          \
pnpm tsx scripts/restore-backup.ts \
  2026-05-08/user-clx123.json.enc \
  /tmp/restored.json

The script downloads the object, decrypts it, and writes the JSON dump to disk. Importing the JSON back into a HealthLog instance is left to the operator (use prisma db seed or a custom script).

Monthly restore drill (automatic)

Since v1.16.4 a pg-boss job (data-restore-drill, cron 11 4 1 * * — 04:11 on the 1st of each month) exercises the read path end-to-end: fetch the most recent backup object from the bucket, decrypt it under the current BACKUP_ENCRYPTION_KEY, JSON-parse it, and sanity-check the payload shape. It performs no database restore — it validates the artefact, not the import path.

Outcomes:

  • Success — record counts, object age, and sizes land in the wide-event meta (job.restore_drill).
  • Stale chain — the newest object is older than 3 days: the nightly uploader has stalled (or the lifecycle rule is too aggressive). The drill pages via the worker error reporter (stderr + GlitchTip).
  • Failure — empty bucket, fetch error, decryption failure (wrong or rotated key), malformed JSON: pages the same way. A decryption failure right after a BACKUP_ENCRYPTION_KEY change means the new key cannot read the existing objects — re-encrypt or accept that pre-rotation backups are only readable with the retired key.
  • Not configured — deployments without the BACKUP_S3_* vars skip silently (wide-event warning only).

The drill needs no IAM grant beyond the uploader's existing GetObject + ListBucket.