Files
HealthLog/docs/ops/backup-restore.md
T
Marc-André Bombeck cfc19a211c fix(backups): give the off-host verdicts slack for a Berlin night that is 25 hours long
The threshold was the schedule period with no budget on top of it. Two things
spend that budget on a host where nothing is wrong. The cron runs 02:30
Europe/Berlin, so on the DST fall-back Sunday the gap between two runs is 25
hours and every account on every host would cross one period on the same
morning. And the ledger instant is when that account's object landed, by
design, so on a cohort walked one account at a time an account reached an hour
later tonight than last night drifts past 24 with two clean runs behind it.

The schedule period and the verdict threshold are now separate numbers. The
period is still what the card quotes; the threshold is the period plus six
hours, applied to both steps, so due still means one run produced nothing and
stale still means two. The fall-back night is pinned in the unit file by its
real dates rather than by an arithmetic 25, and a genuinely missed night at 31
hours still reads due. The runbook says why the slack is there.
2026-09-10 10:09:59 +02:00

18 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 = 0x03             (1 byte)
iv      = 12 random bytes  (AES-GCM nonce)
ciphertext = N bytes       (AES-256-GCM over gzip(JSON dump), key = BACKUP_ENCRYPTION_KEY)
authTag = 16 bytes         (AES-GCM tag, trailing)

Three versions exist in the wild and all three restore. 0x01 encrypted the JSON directly and 0x02 gzipped it first; both carry the tag in FRONT of the ciphertext, which is what made them impossible to write a piece at a time — GCM only produces the tag once the last block is in, so a leading tag means the whole object has to exist before its first byte can be sent. 0x03 moves the tag to the end and changes nothing else: it still covers every ciphertext byte, and the reader still verifies it before returning a single byte of plaintext. Objects already in your bucket stay readable, and the restore script needs no flag to tell them apart.

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 not read by the app; the number for your bucket's lifecycle rule

Bucket permissions

The worker needs PutObject, GetObject and AbortMultipartUpload. It never calls DeleteObject on a backup key, so a compromised worker cannot wipe the history; AbortMultipartUpload only reaches an upload that same worker started and is what clears the parts of a run that failed halfway. Without it, a failed upload leaves parts that are billed and do not show in a bucket listing. On Cloudflare R2 the Object Read & Write token already covers all three.

The worker never deletes a backup object: its grant covers PutObject, GetObject and AbortMultipartUpload only, so a compromised worker cannot wipe the history, and nothing in the app reads BACKUP_RETENTION_DAYS. Expiry is the storage provider's lifecycle rule, set to match the number you wrote there:

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:

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

The credentials are never returned.

Restore

Pick a key (e.g. 2026-05-08/user-clx123.json.enc) from the bucket and run the restore script with the same backup credentials and encryption key the backup was written under — a freshly generated BACKUP_ENCRYPTION_KEY cannot decrypt any existing object:

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=<the key the backup was written under> \
pnpm dlx tsx scripts/restore-backup.ts \
  2026-05-08/user-clx123.json.enc \
  /tmp/restored.json

Run this command from a source checkout with the production backup variables exported. The script imports the full application dependency graph, which the minimal production image does not expose as an operator scripting environment.

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).

Restoring is not streamed and does not need to be. It holds the whole document, because the next thing anyone does with a backup is parse it as one JSON object, and handing back plaintext the auth tag has not yet covered would trade the authentication for memory. It runs on your machine rather than in the container, so give it room: a record of several hundred thousand measurements decompresses to a few hundred megabytes, and NODE_OPTIONS=--max-old-space-size=2048 in front of the command is enough for a 445 000-measurement account.

Restoring from the backups console

An admin can restore a stored snapshot from the browser instead of the shell. /admin/backups lists one row per account and per backup type: Backup now enqueues the data-backup pass for every account on the instance, Download decrypts one snapshot and hands back the JSON, and Restore opens a dialog that first shows what the file carries and then requires the word RESTORE to be typed before it will run. The restore targets the account the snapshot was taken for, never the admin running it, and it refuses a file whose declared owner does not match the stored row. Instance-wide settings ride along only when you tick Also restore instance settings in the dialog; leave it clear to restore the account alone.

A stored copy this instance can no longer open — the key that wrote it dropped from ENCRYPTION_KEYS one rotation too early, or bytes that are not the ones written — is refused by all three buttons with 422 and meta.errorCode = backup.payload.undecryptable, before anything is read back.

What a backup deliberately does not carry

Every credential-shaped row is left out, and this is not an oversight to fix: API tokens, trusted devices, step-up elevations, known devices, clinician share links, and the account grants behind shared record access. Restoring data is rolling a record back to a known state. Restoring an authorization is different in kind, because a grant the owner revoked on Tuesday would come back alive out of Monday's file with nobody deciding it and neither person told.

What this means in practice depends on where you restore to.

Onto the same instance. Nothing changes. The restore replaces the account's data tables and does not touch grants, tokens or devices, so shared access carries on exactly as it was. Somebody who had read access before the restore still has it afterwards, now looking at the restored data.

Onto a fresh instance. None of it comes with the file. Nobody has access to anybody's record, every API token has to be reissued, every device re-trusted, and both people have to invite and accept again before sharing works. That is the fail-safe direction — access lost, never access resumed — and re-consenting is the right amount of ceremony for handing someone your health record a second time. Plan for it rather than discovering it.

The full per-model reasoning lives in src/lib/export/backup-plan.ts, where every excluded model carries a written verdict and a structural test refuses to let a new model land without one.

Container memory (the nightly off-host job)

This is the part that bites, and it bit the nightly job a release after it bit the weekly one. The job runs inside the app process, so V8's heap limit is the app's heap limit, and a container capped at 1 GB gives Node a 524 MB old-space limit by default. A long-lived Next.js server is already holding a large share of that before the job starts.

The uploader used to build the whole backup JSON as one string, gzipped that whole string, ran a whole-buffer cipher pass over the result and handed the finished buffer to a single PutObject — four full copies of the record alive at once. On an account of 445 000 measurements the JSON alone is 242 MB, and the first configured run took the container down seventeen seconds in with FATAL ERROR: Reached heap limit. Because the job shares the app process, one account's size restarted the instance for everybody on it.

It streams now. The JSON is produced a page at a time, gzip and the cipher consume it as it arrives, and the object goes up as a multipart upload that holds two 8 MB parts. What the process holds is fixed by that pipeline's shape rather than by the size of the record going through it: measured on the same 445 000-measurement account under --max-old-space-size=450, the old path died and the new one finished holding tens of megabytes, writing a 9.1 MB object that restores to the identical record.

One ceiling remains, and it is structural rather than a memory bound: a multipart upload carries 10 000 parts, so 80 GB is the largest object one account can produce. Past it the account's backup fails with a clear refusal, is counted in the run's offhost_backup_oversized meta, and the pass carries on with everybody else.

Reading a failed run

A nightly run that could not upload for anybody now fails the pg-boss job instead of reporting success. This is the case wrong credentials, a missing bucket and an unreachable endpoint all land in, and the target's own sentence rides out as the failure cause — The request signature we calculated does not match the signature you provided, The specified bucket does not exist, connect ECONNREFUSED. Check offhost_backup_uploaded against offhost_backup_total_users: before this change a run where every single upload failed still read ok: true, so a bucket could stay empty while the jobs page looked healthy.

A run where SOME account got a copy still succeeds, with the rest counted in offhost_backup_failed. Failing the whole queue over one account's object would re-upload everybody's on every retry.

Which account has no copy

The counts above say how many accounts were uploaded, never which. An account whose object has failed every night for a month reads as 99 uploaded, 1 failed each time, which looks like weather.

Admin console → Backups → Off-host copies answers it per account: when this host last put that account's encrypted copy in the bucket, how big it was, and how that compares with the nightly schedule. Fresh is inside one run, due means one run produced nothing for that account, stale means two did not, and never means no object has ever landed. On a host without BACKUP_S3_* the card says off-host backup is not configured and stops there.

Each verdict carries six hours of slack on top of the schedule's own 24, and that slack is load-bearing on two nights. The cron runs at 02:30 Europe/Berlin, so on the DST fall-back Sunday two consecutive runs are 25 hours apart; and the timestamp is when that account's object landed, so on a cohort walked one account at a time an account reached later tonight than last night drifts by hours. Without the slack a healthy host would paint its whole cohort due once a year.

The verdicts come from a ledger the worker writes when the object lands (offhost_backup_state), not from a listing of the bucket. Keep the worker's grant as documented — PutObject, GetObject, AbortMultipartUpload — and the card still tells the truth. A row that goes stale while the run reports success is that account's object being refused, and the reason is in the run's offhost_backup_failures meta.

The weekly in-database backup (data-backup)

Separate from the off-host job above, and easy to confuse with it. A second pg-boss job writes one WEEKLY_AUTO row per user into data_backups.data — the same JSON document, gzipped and then encrypted under ENCRYPTION_KEY / ENCRYPTION_KEYS, staying inside the instance. It is what /api/admin/backups/<id>/restore reads.

Container memory

This is the part that bites. The job runs inside the app process, so V8's heap limit is the app's heap limit, and a container capped at 1 GB gives Node a 524 MB old-space limit by default. A long-lived record is bigger than it looks: several hundred thousand measurements serialise to a JSON document of a few hundred megabytes, and the writer used to need the object graph and that document resident at the same time. On a seeded account of 445 000 measurements under a 546 MB limit that is FATAL ERROR: Reached heap limit about thirty seconds in — and since the job shares the process, the whole instance restarted and every signed-in session on it went with it.

Two things changed, and both matter to an operator:

  • The writer streams. The three tables that grow without bound — measurements, intake events, mood entries — are read a page at a time and serialised straight into gzip and the cipher, and every other section is released as soon as its JSON exists. On the same fixture doubled to 890 000 measurements, the pass completes inside a 296 MB heap limit; before the change, half that record did not fit in 546 MB.
  • It stops itself, on its own size. The envelope writer counts the encrypted bytes it has produced for an account and gives up on the account whose stored copy would not fit this process — a fifth of V8's heap limit, which is 105 MB on the 524 MB limit a 1 GB container gets. That backup then fails for that one account, is counted in the run's users_failed and records_oversized meta, and the pass carries on with everybody else. A memory failure is a failed job rather than a restart for every user on the host.

If records_oversized is non-zero in job.data_backup, the answer is more memory, not a retry: raise the container's limit, or set NODE_OPTIONS=--max-old-space-size=<MB> to something under it. The limit is derived from the heap limit, so it rises with it.

The check deliberately reads bytes it produced and not the process's heap. A version that read the heap shipped in v1.38.6 and compared the whole process's live usage — garbage included — against 80 % of the limit. A Next.js server that has been up for a week sits at 400 MB of largely collectable heap, so the weekly pass aborted every account on the first chunk, including one whose whole stored copy is 1.2 MB, and reported success while doing it. If you are on v1.38.6, a data-backup run that finishes in seconds having backed up nothing is that defect and not your record.

The stored column is the remaining ceiling

data_backups.data is a single text column, so the finished artifact has to exist as one value before it can be written — that copy cannot be streamed away. It is the only thing left in the job that grows with the record: at a compressed blob of ~42 MB the pass peaks around 236 MB, and the growth from there is roughly twice the blob's size (the base64 answer, plus the driver's copy of it on the way to the wire). That is the ceiling the size check is set against — a fifth of the heap limit, so two copies plus the process's own live set still fit — and an account that reaches it fails as a job with a message naming both numbers. The fix at that point is to stop storing the artifact in one column: chunk it across rows, or keep only the off-host copy. Reading it back has the same shape, and worse: a restore parses the whole document, so the read path needs several times the blob in heap. An operator restoring a very large account should give the container more memory for the duration.

What a restore replaces

A restore replaces the account's data tables; it does not merge into them. Every row the file carries is written back under its original id, and every row the account gained after the snapshot was taken — readings, doses, mood entries, documents — is deleted with the rest of the class before the file is read back. Restoring Monday's copy on Wednesday therefore costs the account everything it recorded on Tuesday. If the current state is worth keeping, take a fresh snapshot from the backups console before restoring the old one.

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.