diff --git a/README.md b/README.md
index edf748bbb..4c880ab87 100644
--- a/README.md
+++ b/README.md
@@ -87,7 +87,7 @@ curl -s http://localhost:3000/api/version # returns version + buildSha + built
`SESSION_COOKIE_SECURE=false` is the one setting plain-HTTP self-hosts must set: without it the session cookie carries the `Secure` flag, the browser drops it over HTTP, and login silently fails. Leave it unset (or `true`) only when a TLS reverse proxy serves HTTPS — and then set `NEXT_PUBLIC_APP_URL` and `APP_URL` to the public URL first (see the [self-hosting guide](https://docs.healthlog.dev/getting-started/self-host-in-10-minutes/)).
-Persistent state lives in exactly one named volume (`postgres-data`, the Postgres data); the app container is stateless, so backing up the database backs up everything. An install from v1.45 or earlier moves its Postgres 16 database into it on the first start ([details](docs/ops/postgres-major-upgrade.md)).
+Persistent state lives in the database volume (`postgres-data`) and in `backups`, which holds the nightly database dumps; the app container is stateless. An install from v1.45 or earlier moves its Postgres 16 database from `pgdata` into the new volume on the first start ([details](docs/ops/postgres-major-upgrade.md)); recovering a lost server is in [disaster-recovery.md](docs/ops/disaster-recovery.md).
NAS and homelab users have ready-made paths: [Unraid Community Applications](docs/self-hosting/unraid.md), [Portainer app templates](docs/self-hosting/portainer/README.md) and [TrueNAS](docs/self-hosting/truenas.md) (the Community train app is pending catalog review).
diff --git a/docs/ops/postgres-major-upgrade.md b/docs/ops/postgres-major-upgrade.md
index 670316b99..ff1c236da 100644
--- a/docs/ops/postgres-major-upgrade.md
+++ b/docs/ops/postgres-major-upgrade.md
@@ -36,8 +36,10 @@ If any step fails, the new database stays empty, the app does not start, and
the log says what happened. A move cut short by a restart or a full disk is
run again from scratch on the next `docker compose up -d`.
-The old `pgdata` volume is read and never changed, apart from the marker
-file. That is the way back.
+The old `pgdata` volume keeps the database as it was. Postgres 16 runs on it
+during the move, so it writes a checkpoint there (and recovers first after an
+unclean stop), and the marker file is added; no data in it changes. That is
+the way back.
## Going back
diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md
index 0cd793b9a..859616446 100644
--- a/docs/self-hosting/getting-started.md
+++ b/docs/self-hosting/getting-started.md
@@ -433,7 +433,9 @@ Keep the dump until the new version has run for a few days, and keep your
encryption key with it: a dump without the key cannot be read.
The database image is a separate matter: a new Postgres minor is a change of
-the `image:` line, a new major (16 to 18) is a dump and restore, described in
+the `image:` line. The move from 16 to 18 runs by itself on the first start
+of the compose file from v1.46; a database you run yourself moves by dump
+and restore. Both are in
[`docs/ops/postgres-major-upgrade.md`](../ops/postgres-major-upgrade.md).
The start log says what is about to happen before the migrations run:
diff --git a/docs/self-hosting/portainer/templates.json b/docs/self-hosting/portainer/templates.json
index bfa70a388..c0cebc054 100644
--- a/docs/self-hosting/portainer/templates.json
+++ b/docs/self-hosting/portainer/templates.json
@@ -5,7 +5,7 @@
"type": 3,
"title": "HealthLog",
"name": "healthlog",
- "description": "Self-hosted personal health tracker (PWA + native iOS client): weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications on one encrypted timeline. AES-256-GCM at rest, no vendor cloud, no telemetry. Ships app + PostgreSQL 16 as one stack.",
+ "description": "Self-hosted personal health tracker (PWA + native iOS client): weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications on one encrypted timeline. AES-256-GCM at rest, no vendor cloud, no telemetry. Ships app + PostgreSQL 18 as one stack.",
"note": "Generate each secret with: openssl rand -hex 32 (a 64-char hex string). For plain-HTTP / LAN access set SESSION_COOKIE_SECURE to false, or login fails. Set NEXT_PUBLIC_APP_URL and APP_URL to how you reach the app. Full guide: https://github.com/MBombeck/HealthLog/blob/main/docs/self-hosting/getting-started.md",
"categories": ["Tools", "Other"],
"platform": "linux",
@@ -33,7 +33,7 @@
{
"name": "HEALTHLOG_PLATFORM",
"label": "Platform hint",
- "description": "Opens the admin's encryption key backup step on the Portainer instructions.",
+ "description": "Opens the admin's recovery kit on the Portainer instructions.",
"default": "portainer"
},
{
diff --git a/docs/self-hosting/reproductive-health-data.md b/docs/self-hosting/reproductive-health-data.md
index 314b65aa2..1fcaec46a 100644
--- a/docs/self-hosting/reproductive-health-data.md
+++ b/docs/self-hosting/reproductive-health-data.md
@@ -95,19 +95,31 @@ different things depending on which button you press.
two actions below, the row stays. A start that was folded into a later
cycle stays for as long as that cycle does, because deleting the later
cycle gives it back.
-- **Settings → Advanced → Delete all data** removes the rows outright, tombstones
+- **Settings → Advanced → Delete all data** removes the rows, tombstones
included. Everything the account owns goes except what you sign in
- with; the confirmation dialog lists it.
-- **Deleting the account** removes the account row, and the database
- cascades every record with it.
+ with; the confirmation dialog lists it. Before it does, it keeps a copy
+ of the account on the server for the deletion grace window (14 days by
+ default), so a mistaken wipe can be restored; the copy is erased when
+ the window ends.
+- **Deleting the account** locks it at once and erases it, with every
+ record, when the grace window ends. Until then an admin can undo it.
-Two consequences follow, and they matter for the seizure threat model
+`ACCOUNT_DELETION_GRACE_DAYS=0` makes both immediate again, without the
+copy and without an undo.
+
+Three consequences follow, and they matter for the seizure threat model
above. A host imaged while a per-entry deletion is the only deletion you
-have performed still holds that entry. And a backup taken before any of
-these actions still holds whatever it captured, because a backup is a
-copy of a moment. If your reason for deleting is that the data must not
-be recoverable from the machine, use one of the two whole-record actions
-and rotate your backups.
+have performed still holds that entry; so does a host imaged inside the
+grace window. A backup taken before any of these actions still holds
+whatever it captured, because a backup is a copy of a moment: the nightly
+database dumps keep a deleted record until they age out (`DB_BACKUP_KEEP`
+nights on the server, and your bucket's lifecycle rule for `database/`).
+And after the move to Postgres 18 the old `pgdata` volume is a full copy
+of the database from that day until you remove it. If your reason for
+deleting is that the data must not be recoverable from the machine, set
+the grace window to 0 or wait it out, remove the old volume, and let the
+dumps age out or delete them
+([backup-restore.md](../ops/backup-restore.md)).
## Practical hardening
diff --git a/docs/self-hosting/unraid/healthlog.xml b/docs/self-hosting/unraid/healthlog.xml
index c68c70c58..895340e41 100644
--- a/docs/self-hosting/unraid/healthlog.xml
+++ b/docs/self-hosting/unraid/healthlog.xml
@@ -46,14 +46,14 @@
3000
-
+
false
- unraid
+ unraid
enforce
diff --git a/tests/integration/demo-seed-shape.test.ts b/tests/integration/demo-seed-shape.test.ts
index d35aa6742..d7557b8bd 100644
--- a/tests/integration/demo-seed-shape.test.ts
+++ b/tests/integration/demo-seed-shape.test.ts
@@ -105,7 +105,9 @@ describe("demo seed, short-window shape", () => {
it("leaves the median of the window in the settled band", () => {
// The usual range is the median ± 3 MAD of the last 30 days. A stretch
// that fills half the window moves that median onto the stretch and
- // widens the band to something like 87-181 mmHg.
+ // widens the band to something like 87-181 mmHg. The seed's own spread
+ // reaches about ±22 in one run of 150, so the bound sits at 24, still far
+ // below what a stretch that long produces.
const sorted = [...systolic].sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)];
expect(median).toBeLessThanOrEqual(124);
@@ -113,7 +115,7 @@ describe("demo seed, short-window shape", () => {
.map((v) => Math.abs(v - median))
.sort((a, b) => a - b);
const mad = deviations[Math.floor(deviations.length / 2)];
- expect(3 * 1.4826 * mad).toBeLessThanOrEqual(20);
+ expect(3 * 1.4826 * mad).toBeLessThanOrEqual(24);
});
it("raises ramipril on the day the stretch turns", () => {