# Account copies and restore HealthLog keeps copies of each account so that one account can be rolled back on this server. These copies are per account. They cannot rebuild a lost server: a restore writes into an account that already exists here under the same id, and refuses a copy whose account is gone. To recover a server, you need a database dump and the encryption keys. That procedure is in [disaster-recovery.md](disaster-recovery.md). ## What exists | Copy | Where | When | Kept | | ----------------------- | ------------------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------------- | | Weekly account rollback | this database | Sundays 03:00, or **Backup now** in Admin → Backups | one per account, replaced in place each week | | Copy before a data wipe | this database | right before "Delete all data" deletes anything | until `ACCOUNT_DELETION_GRACE_DAYS` has passed, then the purge job deletes it | | Uploaded copy | this database | when an admin uploads a file in Admin → Backups | until deleted | | Off-host copy | your S3-compatible bucket | nightly at 02:30 Europe/Berlin, one object per account per day | as long as the bucket's lifecycle rule keeps it | | Personal export | the person's device | Settings → Export, "Full backup" | wherever they keep it | The weekly, pre-wipe and off-host copies carry the whole record, document files and ECG traces included, with the database's ciphertext inside them as stored. An uploaded copy holds whatever its file held. The personal export is portable: readable values, and documents as metadata only. It is meant for the person to keep and read. A restore refuses it when the file lists any document, because it carries no document content to put back. Two kinds of data are not in any account copy: workout GPS routes and sample series. Credentials, devices, share links and account grants are left out on purpose (see "What a restore does not touch"). The JSON import in Settings → Export & import is not a restore. It adds measurements and mood entries, up to 16 MB and 10,000 entries per list. ## The deletion grace window `ACCOUNT_DELETION_GRACE_DAYS` (14 by default, at most 30) sets how long an erasure waits: - **Deleting an account**, or a guardian deleting a **managed profile**, locks the account at once. Every session, refresh token, API token, connector and share link is revoked, sign-in is refused with the date the account is erased, and nothing runs for the account any more: no reminders, syncs, copies, scores or AI work. The record, its stored copies and its off-host objects stay as they are. **Admin → Users** shows the account with its erasure date and an **Undo deletion** button. When the window ends, the hourly `account-deletion-purge` job erases the account and asks the bucket to delete its off-host objects. - **Delete all data** first stores the whole record as a copy of its own (shown as "Before all data was deleted" in Admin → Backups), then empties the record. An admin can restore that copy until the window ends. The off-host objects from before the wipe are kept until then too. Database dumps are not purged per account: a deleted account stays in them until they age out (`DB_BACKUP_KEEP` nights on the server, and the bucket's lifecycle rule for `database/`). The deletion dialogs say so. `0` erases immediately, as releases before 1.46 did. The Data & Privacy page and the deletion dialogs state the configured number. ## Restoring an account In **Admin → Backups**, every stored copy has **Restore**. The dialog first shows what the copy holds, then asks you to type `RESTORE`. The restore runs in the background and replaces the account's data tables: every row the copy holds comes back under its original id, and every row the account gained after the copy was taken is deleted. If the current state is worth keeping, press **Backup now** first. _Also restore instance settings_ writes the registration, MFA, locale, module, notification and AI settings from the copy back for the whole server. Leave it clear unless those settings themselves need to go back. To restore from an **off-host object**, fetch and decrypt it from a source checkout with the backup variables of the server that wrote it: ```bash BACKUP_S3_ENDPOINT=... BACKUP_S3_BUCKET=... \ BACKUP_S3_ACCESS_KEY=... BACKUP_S3_SECRET_KEY=... \ BACKUP_ENCRYPTION_KEY= \ pnpm dlx tsx scripts/restore-backup.ts 2026-05-08/user-.json.enc /tmp/restored.json ``` Then upload `/tmp/restored.json` in Admin → Backups (up to 512 MB after compression) and restore it from its row. The account `` must exist on this server. Every restore, preview and upload checks first that this server holds every application key the copy's content was written under, and refuses with the key id named if not. Nothing is changed by a refused restore. ### What a restore does not touch API tokens, trusted devices, step-up elevations, known devices, clinician share links and account grants are not in any copy. A restore on this server leaves them as they are, so shared access carries on. The reasoning per model is in `src/lib/export/backup-plan.ts`. ## Off-host copies | Variable | Required | Notes | | --------------------------------- | -------- | --------------------------------------------------------------------- | | `BACKUP_ENCRYPTION_KEY` | yes | 64 hex chars or 32-byte base64; not the same as `ENCRYPTION_KEY` | | `BACKUP_S3_ENDPOINT` | yes | e.g. `https://.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 to put in the bucket's lifecycle rule | | `BACKUP_ENCRYPTION_PREVIOUS_KEYS` | no | retired off-host keys, comma-separated, used for reading only | Objects are named `YYYY-MM-DD/user-.json.enc`: the record as JSON, gzipped and sealed with AES-256-GCM under `BACKUP_ENCRYPTION_KEY`, with the object's own name as associated data and the key's id in the header. The nightly database dump goes into the same bucket, as `database//healthlog-