docs: say what deletion keeps and for how long, and drop stale claims

The reproductive-health page promised that deleting all data or the
account erases at once. Both wait for the grace window now, the nightly
dumps keep a record until they age out, and the old pgdata volume is a
full copy until it is removed; the page says so and how to make deletion
final. The README, the getting-started note on majors, the move guide and
the Unraid and Portainer templates catch up with Postgres 18, the backups
volume and the recovery kit. The demo seed's spread bound leaves room for
the seed's own one-in-150 spread.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 16:47:00 +01:00
1 parent 41f2f5ac6c
commit e8c96eea88
7 files changed
+38 -20

No files matched your search

+1 -1
View File
@@ -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).
+4 -2
View File
@@ -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
+3 -1
View File
@@ -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:
+2 -2
View File
@@ -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"
},
{
+22 -10
View File
@@ -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
+2 -2
View File
@@ -46,14 +46,14 @@
<Config Name="WebUI Port" Target="3000" Default="3000" Mode="tcp" Description="HTTP port HealthLog listens on." Type="Port" Display="always" Required="true" Mask="false">3000</Config>
<Config Name="DATABASE_URL" Target="DATABASE_URL" Default="postgresql://healthlog:CHANGE-ME@POSTGRES-HOST:5432/healthlog?schema=public" Mode="" Description="Connection string for your separately-installed PostgreSQL 16. Replace CHANGE-ME with the DB password and POSTGRES-HOST with the Postgres container IP or name." Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="DATABASE_URL" Target="DATABASE_URL" Default="postgresql://healthlog:CHANGE-ME@POSTGRES-HOST:5432/healthlog?schema=public" Mode="" Description="Connection string for your separately-installed PostgreSQL (18, or 16). Replace CHANGE-ME with the DB password and POSTGRES-HOST with the Postgres container IP or name." Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="ENCRYPTION_KEY" Target="ENCRYPTION_KEY" Default="" Mode="" Description="AES-256-GCM key for data at rest. Generate with: openssl rand -hex 32 (64-char hex). Back this up — losing it makes encrypted data unrecoverable." Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="API_TOKEN_HMAC_KEY" Target="API_TOKEN_HMAC_KEY" Default="" Mode="" Description="HMAC key for API token hashing. Generate with: openssl rand -hex 32 (64-char hex)." Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="SESSION_COOKIE_SECURE" Target="SESSION_COOKIE_SECURE" Default="false" Mode="" Description="Set to false for LAN / plain-HTTP installs (http://tower:3000) or login fails. Set to true only behind an HTTPS reverse proxy." Type="Variable" Display="always" Required="true" Mask="false">false</Config>
<Config Name="HEALTHLOG_PLATFORM" Target="HEALTHLOG_PLATFORM" Default="unraid" Mode="" Description="Where this server is configured. Opens the admin's encryption key backup step on the Unraid instructions." Type="Variable" Display="advanced" Required="false" Mask="false">unraid</Config>
<Config Name="HEALTHLOG_PLATFORM" Target="HEALTHLOG_PLATFORM" Default="unraid" Mode="" Description="Where this server is configured. Opens the admin's recovery kit on the Unraid instructions." Type="Variable" Display="advanced" Required="false" Mask="false">unraid</Config>
<Config Name="ENCRYPTION_KEY_CHECK" Target="ENCRYPTION_KEY_CHECK" Default="enforce" Mode="" Description="Start-up encryption key check. enforce refuses to serve when the key does not open the stored data. warn only logs it: a last resort for a refusal you are certain is false, and it disables a safety check." Type="Variable" Display="advanced" Required="false" Mask="false">enforce</Config>
<Config Name="NEXT_PUBLIC_APP_URL" Target="NEXT_PUBLIC_APP_URL" Default="http://tower:3000" Mode="" Description="Public URL users reach HealthLog at. Use the LAN URL (e.g. http://tower:3000) or your HTTPS domain behind a reverse proxy." Type="Variable" Display="always" Required="true" Mask="false"/>
+4 -2
View File
@@ -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", () => {