Merge the release into the backup health work and read the real dump health

The readiness model now takes the database dump's state from
getDatabaseBackupHealth, and the dump test reaches the off-host key id
where it moved.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 14:40:31 +01:00
commit e88dcd2c53
99 files changed
+6106 -492

No files matched your search

+13 -7
View File
@@ -17,9 +17,13 @@ notes, and machine-readable specs that ship alongside the source:
encryption-key rotation, password reset and account recovery, env
checks, TLS pinning, data repair. These are what an operator reaches
for during an incident.
- [`self-hosting/`](./self-hosting/) — horizontal-scaling notes
(`HEALTHLOG_PROCESS_TYPE=web|worker|all`) and the deploy-pipeline
recipe. Routine install steps stay on `docs.healthlog.dev`. Includes
- [`self-hosting/`](./self-hosting/) — operator notes next to the code:
[`getting-started.md`](./self-hosting/getting-started.md) (install and
updates, the page the boot log points at), the TrueNAS, Unraid and
Portainer installs with their catalog templates, Tailscale, the reverse
proxy, horizontal scaling (`HEALTHLOG_PROCESS_TYPE=web|worker|all`),
notification channels, the environment module, document sources and
reproductive-health data. Includes
[`self-hosting/mcp.md`](./self-hosting/mcp.md) — enabling the
off-by-default MCP connector and pointing Claude.ai / ChatGPT /
Claude Desktop at it, and [`self-hosting/sso.md`](./self-hosting/sso.md)
@@ -44,9 +48,11 @@ notes, and machine-readable specs that ship alongside the source:
- [`migration/`](./migration/) — migration notes. Currently only
v1.3 to v1.4; later releases are covered by `CHANGELOG.md` and
<https://docs.healthlog.dev>.
- [`integrations/`](./integrations/) — per-integration reference
(Withings, Fitbit, Google Health, WHOOP, Apple Health, AI providers,
data import).
- [`integrations/`](./integrations/) — per-integration reference:
Withings, WHOOP, Fitbit, Google Health, Strava, Nightscout, Dawarich and
the Garmin route; the Apple Health, Health Connect, document and data
imports; AI providers; and Home Assistant with its blueprint. Oura and
Polar are covered on the docs site only.
- [`adr/`](./adr/) — architecture decision records.
- [`security/`](./security/) — threat-model notes.
- [`diagrams/`](./diagrams/) — architecture diagrams rendered through
@@ -70,4 +76,4 @@ notes, and machine-readable specs that ship alongside the source:
If you're a self-hoster looking to spin up an instance, start at
<https://docs.healthlog.dev> or jump to the [Quick Start in the project
README](../README.md#quick-start).
README](../README.md#quick-start-docker-compose).
+34 -35
View File
@@ -82137,7 +82137,7 @@ components:
- type: "null"
syncHealth:
deprecated: true
description: "Deprecated: removed in v1.44, read `health`. Present on the GET read; omitted from the PATCH echo."
description: "Deprecated, still served: read `health`. Present on the GET read; omitted from the PATCH echo."
$ref: "#/components/schemas/SyncHealth"
metricFreshness:
description: Per-metric-type freshness for the `APPLE_HEALTH` source, with the per-type arrival facts. Present on the
@@ -82815,7 +82815,7 @@ components:
type: boolean
state:
deprecated: true
description: "Deprecated: removed in v1.44, read `health`."
description: "Deprecated, still served: read `health`."
type: string
enum:
- connected
@@ -82838,14 +82838,14 @@ components:
- type: "null"
lastError:
deprecated: true
description: "Deprecated: removed in v1.44, read `health`. Last recorded failure message, decrypted from the ledger.
Null when the last attempt succeeded or none has run."
description: "Deprecated, still served: read `health`. Last recorded failure message, decrypted from the ledger. Null
when the last attempt succeeded or none has run."
type:
- string
- "null"
syncHealth:
deprecated: true
description: "Deprecated: removed in v1.44, read `health`."
description: "Deprecated, still served: read `health`."
$ref: "#/components/schemas/SyncHealth"
health:
description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to
@@ -83034,9 +83034,9 @@ components:
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
tokenExpired:
deprecated: true
description: "Deprecated: removed in v1.44, read `health`. The stored access token's expiry is in the past. Read
straight off the row — unlike the Withings status read, this endpoint does not refresh; the sync path
refreshes lazily instead."
description: "Deprecated, still served: read `health`. The stored access token's expiry is in the past. Read straight
off the row — unlike the Withings status read, this endpoint does not refresh; the sync path refreshes
lazily instead."
type: boolean
tokenExpiresAt:
type: string
@@ -83125,8 +83125,7 @@ components:
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
tokenExpired:
deprecated: true
description: "Deprecated: removed in v1.44, read `health`. True after a refresh attempt that did not produce a live
token."
description: "Deprecated, still served: read `health`. True after a refresh attempt that did not produce a live token."
type: boolean
tokenRefreshFailed:
description: The read tried to refresh an expired token and could not — either the per-user credentials are gone or
@@ -83146,8 +83145,8 @@ components:
- "null"
hasActivityScope:
deprecated: true
description: "Deprecated: removed in v1.44, read `health`. Pre-computed convenience flag the reconnect banner reads. A
null `scope` counts as missing activity."
description: "Deprecated, still served: read `health`. Pre-computed convenience flag the reconnect banner reads. A null
`scope` counts as missing activity."
type: boolean
health:
description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to
@@ -83456,8 +83455,8 @@ components:
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
tokenExpired:
deprecated: true
description: "Deprecated: removed in v1.44, read `health`. Read straight off the row — this endpoint does not refresh;
the sync path refreshes lazily."
description: "Deprecated, still served: read `health`. Read straight off the row — this endpoint does not refresh; the
sync path refreshes lazily."
type: boolean
tokenExpiresAt:
type: string
@@ -83627,8 +83626,8 @@ components:
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
tokenExpired:
deprecated: true
description: "Deprecated: removed in v1.44, read `health`. Read straight off the row — this endpoint does not refresh;
the sync path refreshes lazily."
description: "Deprecated, still served: read `health`. Read straight off the row — this endpoint does not refresh; the
sync path refreshes lazily."
type: boolean
tokenExpiresAt:
type: string
@@ -83678,7 +83677,7 @@ components:
when every type was read to its start. Kept after the backfill completes; cleared on reconnect.
needsReauth:
deprecated: true
description: "Deprecated: removed in v1.44, read `health`. The stored refresh token was rejected with `invalid_grant` —
description: "Deprecated, still served: read `health`. The stored refresh token was rejected with `invalid_grant` —
Google expires it after 7 days while the OAuth client is in Testing publishing mode, and a user-revoked
grant lands here too. Distinct from `parked` and from `disconnected`: the connection still exists, it just
cannot mint a token until the user reconnects. Only Google Health has this field."
@@ -83882,7 +83881,7 @@ components:
description: The user has stored their own BYO client id and secret.
state:
deprecated: true
description: "Deprecated: removed in v1.44, read `health`."
description: "Deprecated, still served: read `health`."
type: string
enum:
- connected
@@ -83905,13 +83904,13 @@ components:
- type: "null"
lastError:
deprecated: true
description: "Deprecated: removed in v1.44, read `health`."
description: "Deprecated, still served: read `health`."
type:
- string
- "null"
syncHealth:
deprecated: true
description: "Deprecated: removed in v1.44, read `health`."
description: "Deprecated, still served: read `health`."
$ref: "#/components/schemas/SyncHealth"
health:
description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to
@@ -84306,7 +84305,7 @@ components:
- disconnected
- parked
- unknown
description: "Deprecated: removed in v1.44, read `health`."
description: "Deprecated, still served: read `health`."
deprecated: true
lastSuccessAt:
anyOf:
@@ -84322,7 +84321,7 @@ components:
- type: "null"
lastError:
deprecated: true
description: "Deprecated: removed in v1.44, read `health`."
description: "Deprecated, still served: read `health`."
type:
- string
- "null"
@@ -84349,11 +84348,11 @@ components:
additionalProperties: false
- type: "null"
deprecated: true
description: "Deprecated: removed in v1.44, read `health`. Per-kind consecutive-failure counters, or null when the row
has never written a bucket payload. Compare against the top-level `threshold` to know how close the provider
is to the operator alert."
description: "Deprecated, still served: read `health`. Per-kind consecutive-failure counters, or null when the row has
never written a bucket payload. Compare against the top-level `threshold` to know how close the provider is
to the operator alert."
syncHealth:
description: "Deprecated: removed in v1.44, read `health`."
description: "Deprecated, still served: read `health`."
deprecated: true
$ref: "#/components/schemas/SyncHealth"
metricFreshness:
@@ -84390,7 +84389,7 @@ components:
- type: "null"
tokenExpired:
deprecated: true
description: "Deprecated: removed in v1.44, read `health`. Null — not false — when there is no connection to judge."
description: "Deprecated, still served: read `health`. Null — not false — when there is no connection to judge."
type:
- boolean
- "null"
@@ -84433,14 +84432,14 @@ components:
- "null"
hasActivityScope:
deprecated: true
description: "Deprecated: removed in v1.44, read `health`. Withings only."
description: "Deprecated, still served: read `health`. Withings only."
type: boolean
planUnavailable:
deprecated: true
description: "Deprecated: removed in v1.44, read `health`. Withings only. The readings this connection's Withings API
plan withholds (status 277 since the 2026-10-12 plan split), deduplicated and in display order. Empty when
the plan refuses nothing. The sync skips these and keeps importing everything else; the list refreshes when
the weekly re-probe asks again."
description: "Deprecated, still served: read `health`. Withings only. The readings this connection's Withings API plan
withholds (status 277 since the 2026-10-12 plan split), deduplicated and in display order. Empty when the
plan refuses nothing. The sync skips these and keeps importing everything else; the list refreshes when the
weekly re-probe asks again."
type: array
items:
type: string
@@ -84469,7 +84468,7 @@ components:
- weight
needsReauth:
deprecated: true
description: "Deprecated: removed in v1.44, read `health`. Google Health only."
description: "Deprecated, still served: read `health`. Google Health only."
type: boolean
available:
description: Oura / Polar / Strava only.
@@ -84479,7 +84478,7 @@ components:
type: boolean
scopeReconnect:
deprecated: true
description: "Deprecated: removed in v1.44, read `health`. Oura only. Collections the stored grant does not cover: Oura
description: "Deprecated, still served: read `health`. Oura only. Collections the stored grant does not cover: Oura
refused them for a missing scope (named in its 401, or inferred when sibling collections succeeded with the
same token), or the scopes Oura reported at connect leave theirs out. Not a failure and not counted toward
the reconnect threshold; one reconnect asks for the missing scopes. Empty when the grant covers everything
@@ -84535,7 +84534,7 @@ components:
description: The unified connection state, null when the provider is not connected (and for Fitbit once
`retirement.ended`). It supersedes `state`, `lastError`, `consecutiveFailuresByKind`, `syncHealth`,
`tokenExpired`, `needsReauth`, `hasActivityScope`, `planUnavailable` and `scopeReconnect` on this entry;
those nine are deprecated and removed in v1.44.
those nine are deprecated and still served for clients that predate `health`.
required:
- integration
- state
+1 -1
View File
@@ -295,7 +295,7 @@ Concrete consequences:
RMSSD HRV has its own ladder, **WHOOP ≻ OURA ≻ POLAR ≻ APPLE_HEALTH**,
separate from the SDNN one.
Override per-user via Settings → Sources (`/settings/sources`).
Override per-user via Settings → Source priority (`/settings/sources`).
## Failure modes
+5 -80
View File
@@ -1,82 +1,7 @@
# Dawarich integration
HealthLog can read daily movement context from a [Dawarich](https://dawarich.app)
instance: how far you moved, how much of it by car or by train, how many places
you visited, how long you were away from home, and whether a day was spent in
another country. It is part of the Environment module and runs only while that
module is on.
HealthLog never stores a coordinate, a track, a place name or a visit. It reads
the day summaries and keeps one row of numbers per day. The place list is read
live when you pick your home and is not saved.
## What is read
Once a night (03:20 Europe/Berlin) HealthLog fetches the last seven days, ending
yesterday. Connecting fetches the last 30 days. Each request carries your API
key as `Authorization: Bearer <key>`.
| Dawarich endpoint | What HealthLog keeps |
| ----------------------- | -------------------------------------------------------------------------------------- |
| `GET /api/v1/timeline` | total distance, distance by car (`driving`), distance by train, places visited |
| `GET /api/v1/visits` | minutes spent at your home place, to work out time away from home |
| `GET /api/v1/residency` | the country of each day, sealed at rest, and whether it differs from your home country |
| `GET /api/v1/places` | nothing; the list is shown once so you can pick your home |
| `GET /api/v1/users/me` | nothing; used to check the key |
Time away from home is the day's length minus the time a visit at your home
place covers. Without a home place, or on a day without a single visit, it is
left empty rather than shown as zero. Dawarich leaves out a mode that moved
less than half a kilometre; HealthLog then reads that mode as zero for the day.
Residency needs API access on a hosted Dawarich plan; a self-hosted instance
has it. Without it there are no travel days, and everything else still works.
Dawarich keys residency by UTC date, so on a day that crosses a border late in
the evening the country can be the next morning's.
## Connect
1. In Dawarich, open your account settings and copy the API key.
2. In HealthLog, turn on the Environment module, then open
**Settings → Integrations → Dawarich**.
3. Enter the address of the instance and the key, and connect.
4. Pick your home place from the list, and set your home country if you like.
Without one, the country you spent most days in over the last year counts.
Run Dawarich and HealthLog in the same time zone. Dawarich groups the
timeline by its own zone, HealthLog by yours; if they differ, a day can come
back without its distances.
## A Dawarich instance on your own network
A public instance works without any server setting. An instance on a private
network (a LAN address, a `.lan` name, a container on the same host) is refused
unless the operator lists its exact origin:
```env
DAWARICH_PRIVATE_ORIGINS="http://dawarich.lan:3000"
```
The grammar is the one `NIGHTSCOUT_PRIVATE_ORIGINS` uses
([Nightscout](./nightscout.md)): one `scheme://host[:port]` per entry, comma
separated, no path, no wildcard and no range. Listing a host does not open its
subdomains or another port. The unspecified address, link-local and the
cloud-metadata range can never be listed. Restart HealthLog after changing it.
The variable is on the `environment:` whitelist in `docker-compose.yml`.
## Where the values show up
- The day view has an **On the move** section with the day's figures.
- Time away from home and the distance travelled join the correlation scan as
two more factors.
- Runs of at least two travel days that no trip covers yet are offered as
trips under **Settings → Environment → Location periods**. Using one fills
in the dates; you pick the place.
- The Coach and the MCP `get_day` tool see the figures and whether it was a
travel day, never the country.
## Disconnect
Disconnecting stops the nightly fetch and keeps the stored days. **Disconnect
and delete days** removes them as well. The days are part of the backup; the
address and key are not, so a restored account connects again.
The Dawarich guide lives on the docs site:
<https://docs.healthlog.dev/guides/connect-dawarich/>. It covers what is read
and what is never stored, connecting an instance, allowing one on your own
network (`DAWARICH_PRIVATE_ORIGINS`), where the values show up, and
disconnecting.
+1 -1
View File
@@ -103,7 +103,7 @@ The Fitbit client id/secret are per-user (Settings), not env vars.
If Fitbit and another source (Withings, WHOOP, Apple Health) both supply the same
vital — resting heart rate, blood oxygen, body temperature or respiratory rate —
the source priority ladder in **Settings → Sources** decides which one wins
the source priority ladder in **Settings → Source priority** decides which one wins
per metric. FITBIT is ranked in every default metric ladder; override
per metric on that page.
+1 -1
View File
@@ -305,7 +305,7 @@ triggers the verification + CASA requirements above.)
If Google Health and another source (Withings, WHOOP, Apple Health) both supply
the same vital — resting heart rate, blood oxygen, respiratory rate — the source
priority ladder in **Settings → Sources** decides which one wins
priority ladder in **Settings → Source priority** decides which one wins
per metric.
## Disconnect
+4 -72
View File
@@ -1,74 +1,6 @@
# Strava integration
HealthLog reads your Strava activities and folds them into the same workout
history as every other source. Strava is a **workout source only** — its API is
activity-centric and exposes no sleep, recovery, body-composition or glucose
data, so connecting it adds runs, rides, swims and the rest to your workout
surface and nothing else.
The OAuth app is brought **per user**: each self-hoster registers their own
Strava API application and pastes the client id/secret into Settings. Strava
caps every newly-created app at "single-player mode" (an athlete capacity of 1)
until you request a capacity increase — perfectly fine for a personal
self-host, and the reason a single shared app cannot serve many operators.
## What you get — and what you don't
**Covered** (one `Workout` row per activity): sport type, start and end time,
moving duration, distance, elevation gain, average and maximum heart rate (when
the activity recorded it), and calories. Power, cadence, the "trainer"/"commute"
flags and the activity name are kept on the row's metadata for provenance but
are **not** surfaced as training analytics — HealthLog treats Strava as another
workout source, not a training-load platform.
**Not covered**: Strava exposes no daily metrics (steps, sleep, resting heart
rate, weight, …). Those come from your wearable's own integration or from Apple
Health / Google Health Connect.
## Duplicate activities are collapsed automatically
If you record a run on an Apple Watch **and** it also lands in Strava, the same
run arrives twice — once via Apple Health, once via Strava. HealthLog keeps one
canonical row per logical workout using your **source-priority ladder**: by
default the device-native capture (Apple Watch, WHOOP, Withings) wins over the
Strava copy, because a Strava activity is often a re-upload with lower-fidelity
or missing heart-rate data. A run you record **only** on Strava (phone GPS)
survives untouched. You can promote Strava above the wearables in
**Settings → Sources** if you prefer the Strava copy.
## 1. Register a Strava API application
1. Sign in at <https://www.strava.com/settings/api>.
2. Create an application. For **Authorization Callback Domain** enter the host
of your HealthLog instance (the domain only, no scheme or path).
3. Copy the **Client ID** and **Client Secret**.
## 2. Paste the credentials into HealthLog
1. Open **Settings → Integrations** and find the **Strava** card.
2. Paste the Client ID and Client Secret into the **API Credentials** fields and
save. (An operator can instead configure a shared app via the
`STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET` environment variables; per-user
credentials always take precedence.)
3. Click **Connect Strava** and approve the access request. HealthLog requests
the `activity:read_all` scope so it can read every activity, including those
you marked "only me".
## 3. First sync
The first history import runs in the background shortly after you connect;
afterwards an hourly poll pulls new activities. Strava rotates its refresh token
on every refresh, and HealthLog persists the rotated token each time, so the
connection stays healthy without you re-authorising.
## Disconnecting
Disconnecting revokes the grant at Strava and clears the stored tokens.
Activities already imported stay in your history; reconnecting resumes the sync.
## Related
- Wearable that syncs to Strava but not directly to HealthLog? See the device's
own page, or ingest via [Apple Health](./apple-health.md) /
[Google Health Connect](./google-health.md).
- **Garmin owners:** Garmin has no direct connector — see [garmin.md](./garmin.md).
The Strava guide lives on the docs site:
<https://docs.healthlog.dev/guides/connect-strava/>. It covers what Strava
adds (workouts only), how duplicate activities from a wearable are collapsed,
registering the Strava API application, the first sync and disconnecting.
+4 -4
View File
@@ -88,7 +88,7 @@ or right away after reconnecting Withings once.
needed — `user.metrics` covers weight / BP / heart-list / sleep /
SpO₂ / body comp / temperature / VO₂ max; `user.activity` is the
workout-equivalent stream for steps / energy / distance / floors.
See `WITHINGS_OAUTH_SCOPE` in `src/lib/withings/client.ts`.
See `WITHINGS_OAUTH_SCOPE` in `src/lib/withings/oauth.ts`.
4. Save the application. Withings will issue a **Client ID** and a
**Client Secret** — keep the secret out of any chat log; it grants
read access to every linked account's full health history.
@@ -145,7 +145,7 @@ callback. The portal only owns the OAuth callback URL.
## 4. Link from the Settings page
With the credentials saved in Settings, open
`/settings/integrations/withings` and click **Connect Withings**. The flow:
`/settings/integrations#withings` and click **Connect Withings**. The flow:
1. The Connect button redirects to Withings' `oauth2/authorize`.
2. The user signs in on Withings and grants the requested scopes.
@@ -181,7 +181,7 @@ import:
when Apple Health is also linked, so the Apple Health stream wins
for the cumulative axis. The Withings rows stay in the DB.
Override per-user via Settings → Sources (`/settings/sources`).
Override per-user via Settings → Source priority (`/settings/sources`).
## Troubleshooting
@@ -199,7 +199,7 @@ Override per-user via Settings → Sources (`/settings/sources`).
### The webhook stops firing
Webhook subscriptions can lapse after a token revoke or a portal-
side cleanup. Re-subscribe by opening `/settings/integrations/withings`
side cleanup. Re-subscribe by opening `/settings/integrations#withings`
and clicking **Disconnect** then **Connect Withings** again — the
callback exchange re-runs `Notify.subscribe()` with a fresh callback
URL.
+13
View File
@@ -5,6 +5,11 @@ 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.
These per-account copies roll one account back on the same server. To
rebuild a server, use the nightly database dump, which the same job
configuration also uploads (`database/healthlog-<time>-daily.dump.enc`, in the
envelope below): [`disaster-recovery.md`](disaster-recovery.md).
The backup runs at **02:30 Europe/Berlin** every day from the worker
container (queue `data-backup-offhost`). Object key layout:
@@ -72,6 +77,14 @@ only those:
was deleted is removed by the upload itself.
- **The connection test** deletes its own `_healthcheck/<ts>.bin`.
The database dumps are not purged per account. A dump is the whole database
at one moment, so an account deleted since stays in the dumps taken before
its deletion until they age out: after `DB_BACKUP_KEEP` nights on the server,
and in the bucket after the lifecycle rule for `database/`. That is backup
retention, and the period to state to the people using your instance; the
backups page shows both numbers and warns when no rule covers `database/`
([`disaster-recovery.md`](disaster-recovery.md)).
The admin wipe of every account deletes nothing in the bucket. It is you
clearing your own host, the bucket is yours, and its copies are the way back
from a wipe pressed by mistake. If you are handing the host on, empty the
+29 -13
View File
@@ -13,11 +13,13 @@ Migrations land **before** the new app container takes traffic. The
canonical command sequence on a single-instance host:
```sh
# 0. Dump the database first. A migration is not undone by going back to
# the previous image; the dump is the way back. See "Updates" in
# docs/self-hosting/getting-started.md.
docker compose exec -T db pg_dump -U healthlog healthlog \
| gzip > healthlog-before-update-$(date +%F).sql.gz
# 0. Nothing to do by hand for the way back: when the new image brings
# migrations, its entrypoint first writes
# healthlog-<time>-pre-migration.dump into DB_BACKUP_DIR (the `backups`
# volume) and keeps the last two. A migration is not undone by going back
# to the previous image; that dump is the way back
# (docs/ops/disaster-recovery.md, "Going back to before a migration").
# Copy it off the host if the host itself is what you are worried about.
# 1. Pull the new image. If you pin a version with HEALTHLOG_IMAGE_REF,
# set it to the new one first.
@@ -33,6 +35,14 @@ docker compose run --rm app sh -c 'exit 0'
docker compose up -d app
```
Step 2 takes the pre-migration dump too, in the one-off container, before it
migrates. When the backups directory is writable and that dump fails (a full
disk, most often), the entrypoint stops before migrating and says so; free
the space and run the step again, or set `DB_BACKUP_BEFORE_MIGRATE=false` for
one start without the dump. Without a writable directory (an install that
runs the image without the `backups` volume) the log carries a warning, no
dump is taken, and the migrations run.
Use `docker compose run` rather than a plain `docker run` for step 2. A
plain `docker run` starts outside the stack's network, where `db` does not
resolve, and the entrypoint gives up after thirty seconds with "Database
@@ -230,12 +240,15 @@ roll back:
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
commit, and re-deploy. Never `migrate resolve --rolled-back` against
production unless you've taken a fresh `pg_dump` first.
4. Rolling back from v1.40.2 or later to v1.40.1 or earlier: an account
that saved the Coach lookback "Last year" reads its whole Coach
preference blob as defaults on the older version, metric exclusions
included, until the setting is saved again. Ask affected accounts to
pick another lookback before the rollback, or re-save their exclusions
after it.
When the data itself has to go back to before the update, restore the
pre-migration dump with the previous image; the steps are in
[`disaster-recovery.md`](disaster-recovery.md#going-back-to-before-a-migration). 4. Rolling back from v1.40.2 or later to v1.40.1 or earlier: an account
that saved the Coach lookback "Last year" reads its whole Coach
preference blob as defaults on the older version, metric exclusions
included, until the setting is saved again. Ask affected accounts to
pick another lookback before the rollback, or re-save their exclusions
after it.
## Catalog templates
@@ -270,10 +283,13 @@ always confirm the served build after a deploy:
```sh
# Asserts prod + demo serve 1.9.0, prints each target's buildSha,
# exits non-zero on a mismatch or timeout after a bounded retry loop.
pnpm dlx tsx scripts/assert-deploy.ts 1.9.0
HEALTHLOG_PROD_URL=https://healthlog.example.com \
pnpm dlx tsx scripts/assert-deploy.ts 1.9.0
```
Pass `--only=prod` / `--only=demo` to check a single target, or
`HEALTHLOG_PROD_URL` is the base URL of your instance; it has no default and
is required unless you pass `--only=demo`. Pass `--only=prod` /
`--only=demo` to check a single target, or
`--attempts=N --interval=ms` to widen the retry window while a slow
pull settles.
+185
View File
@@ -0,0 +1,185 @@
# Disaster recovery
The copy that rebuilds a lost server is the database dump. This page says
what is dumped and where it goes, which keys a restore needs, how to restore
onto a new server, and how the procedure is tested.
## What is backed up, and where
| Copy | When | Where | Kept |
| ----------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------- |
| Nightly dump | 01:30 Europe/Berlin, the zone every schedule runs in (`DB_BACKUP_SCHEDULE`) | `DB_BACKUP_DIR`, the `backups` volume of the bundled compose file | the newest 7 (`DB_BACKUP_KEEP`) |
| Pre-migration dump | on start, when the new image brings migrations, before them | the same directory | the newest 2 |
| Off-host copy of a dump | right after each nightly dump, if the bucket is configured | your bucket, under `database/`, sealed with `BACKUP_ENCRYPTION_KEY` | your bucket's lifecycle rule |
A dump is `pg_dump --format=custom` of the whole database: every account,
sign-ins, passkeys, sharing grants and managed profiles, documents, the job
queue and the instance settings. It is two files:
- `healthlog-<UTC time>-<daily|pre-migration>.dump`, the archive;
- `healthlog-<UTC time>-<daily|pre-migration>.manifest.json` beside it: the
app version and build, the newest applied migration, the Postgres version,
the archive's size and SHA-256, the row count of every table, and the
fingerprints (never the values) of the keys the database needs.
A dump counts as good only when both files exist under their final names. The
archive is written as `.partial`, read back with `pg_restore --list`, and
renamed after its manifest; a run that fails leaves nothing that looks good.
The admin backups page shows the newest good dump, where it is, and the
newest failed run. The start log says on every boot where dumps go, or that
none can be kept.
Local dumps are not encrypted. They sit beside the database volume, in the
same trust zone: whoever can read one volume can read the other. The columns
HealthLog encrypts stay encrypted inside the dump, under `ENCRYPTION_KEYS`.
Keep a copy of the dumps off the host; the off-host bucket does that for you,
and it seals each copy with `BACKUP_ENCRYPTION_KEY` in the envelope the
per-account copies use.
A dump holds every account that existed when it was taken, including one
deleted since. Deleted accounts leave the dumps as they age out: after
`DB_BACKUP_KEEP` nights on the server, and after your lifecycle rule in the
bucket. Give that rule a prefix that covers `database/` too; the admin
backups page reads the bucket's rules and says when none does. This is the
retention period to state where you tell people that deleting their account
deletes their data.
Without a writable `DB_BACKUP_DIR` (an install that runs the image without the
`backups` volume), the nightly dump is uploaded off-host and nothing is kept
on the server, and no dump is taken before migrations; with no bucket either,
no dump is taken at all. The start log and the admin page say so.
The per-account copies (the weekly in-database copy, the nightly per-account
objects) roll one account back on the same server. They cannot rebuild a
server; see [`backup-restore.md`](backup-restore.md).
## The keys a restore needs
| Key | Without it |
| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `ENCRYPTION_KEYS` + `ENCRYPTION_ACTIVE_KEY_ID`, or `ENCRYPTION_KEY` | Nothing encrypted can be read. The restore refuses when the active key is missing or different. Keep every retired id still in the map. |
| `API_TOKEN_HMAC_KEY` | No data is lost, but every session, app token and share link stops working; people sign in again and re-create tokens. |
| `BACKUP_ENCRYPTION_KEY` (and `BACKUP_ENCRYPTION_PREVIOUS_KEYS`) | Off-host copies do not open. |
| `POSTGRES_PASSWORD` | Not needed: a new server can use a new one. |
The restore compares the keys in its environment with the fingerprints in the
manifest before it writes anything, and names each key that is missing or
different.
## Restore onto a new server
1. Install Docker and take the `docker-compose.yml` of the version that wrote
the dump or a newer one. The manifest's `appVersion` names it; to run that
exact version set `HEALTHLOG_IMAGE_REF=:v<version>` in `.env`.
2. Write `.env` with the keys from your records (see above) and a new
`POSTGRES_PASSWORD`.
3. Do not run `docker compose up` yet. The app would create an empty schema,
and the restore only writes into a database with no tables. Start only the
database:
```bash
docker compose up -d db
```
4. Restore. When the host is lost, its local dumps went with it, and the
copy is the one in your bucket: put the `BACKUP_S3_*` variables and
`BACKUP_ENCRYPTION_KEY` (with any `BACKUP_ENCRYPTION_PREVIOUS_KEYS`) in
`.env` as on the old server, then
```bash
docker compose run --rm db-restore list --from-bucket
docker compose run --rm db-restore latest --from-bucket
```
`latest --from-bucket` downloads the newest `database/…dump.enc` whose
manifest is in the bucket too, opens both with `BACKUP_ENCRYPTION_KEY`
and restores. Name a dump instead of `latest` to take an older one.
With the files in hand instead (copied off the host, or downloaded from
the bucket with your own tools: both `<name>.dump.enc` and
`<name>.manifest.json.enc`, or a `.dump` with its `.manifest.json`), put
them in a directory such as `./restore`, make them readable for the
container (`chmod a+r restore/*`) and name the dump:
```bash
docker compose run --rm -v "$PWD/restore:/restore:ro" db-restore list /restore
docker compose run --rm -v "$PWD/restore:/restore:ro" db-restore \
/restore/healthlog-20261024T233000Z-daily.dump
```
Either way it checks the archive against the manifest's SHA-256, the keys
against the manifest's fingerprints, and that the database is empty. Then
it restores in one transaction, gathers planner statistics and counts
every table against the manifest. A count that differs empties the
database again and fails. Wait for the line that starts with `Restored`.
5. Start the stack and check it:
```bash
docker compose up -d
curl -s http://localhost:3000/api/health
```
Sign in. The app's own key check runs at start and refuses to serve on a
key that does not open the data.
When the dumps are still in the server's `backups` volume (the database
volume was lost, the backups volume was not), step 4 is
`docker compose run --rm db-restore latest`.
`--ignore-key-mismatch` restores although the active encryption key differs.
Use it only when that key is lost for good: everything encrypted stays
unreadable.
## Going back to before a migration
The entrypoint dumps the database before it applies migrations. To return to
that state (the previous image, the data as it was before the update):
```bash
docker compose stop app
# Keep the current state too, in case it is wanted later.
docker compose exec -T db pg_dump -U healthlog -Fc healthlog > healthlog-current.dump
docker compose exec db dropdb -U healthlog healthlog
docker compose exec db createdb -U healthlog healthlog
docker compose run --rm db-restore list
docker compose run --rm db-restore healthlog-<time>-pre-migration.dump
# Set HEALTHLOG_IMAGE_REF in .env to the version you ran before the update.
docker compose up -d
```
The dump before a migration is taken while the new image starts. If it fails
on a writable directory (a full disk, most often), the start stops before
anything is migrated and the log names the switch: `DB_BACKUP_BEFORE_MIGRATE=false`
starts without it, once.
## How this is tested
`scripts/compose-dr-drill.sh` plays this page against real containers: it
builds the image, seeds several accounts (the demo account with documents
and encrypted notes, a Guardian with a managed profile), lets the scheduled
job take the dump and upload it to an S3-compatible bucket, destroys every
volume of the stack (the local dumps included), and restores on a new stack
given only the bucket and the keys
(`docker compose run --rm db-restore latest --from-bucket`). A second run
restores from the dump files instead (`DRILL_SOURCE=local`). Both then count
every table against the manifest and boot the app: health, sign-in, an
encrypted note read back, a document opened, the Guardian still seeing the
profile. It runs in CI nightly and on every change to the
compose file, the image or the dump and restore code
(`.github/workflows/compose-dr-drill.yml`), and it is checked to fail on a
wrong encryption key, a wrong off-host key, a dump that lost a table's rows
and a target that is not empty.
Test your own copies too: once, and after any change to how the server is
run, restore last night's dump on a spare machine with the steps above.
## Settings
| Variable | Default | What it does |
| -------------------------- | ---------------------------- | -------------------------------------------------------------------------------- |
| `DB_BACKUP_ENABLED` | on | `false` stops the nightly dump. |
| `DB_BACKUP_DIR` | `/var/lib/healthlog/backups` | Where dumps are written and read. Change it only together with the volume mount. |
| `DB_BACKUP_SCHEDULE` | `30 1 * * *` | Five-field cron, Europe/Berlin time like every HealthLog schedule. |
| `DB_BACKUP_KEEP` | `7` | Nightly dumps kept (at least 1). Two pre-migration dumps are kept besides. |
| `DB_BACKUP_BEFORE_MIGRATE` | on | `false` starts an update without the dump before its migrations. |
+3 -2
View File
@@ -96,8 +96,9 @@ maintenance refuses to start while one is.
Have at least twice the largest size free on the database volume.
- **Take a backup** (Settings, Data, Backups), as before any operation on the
whole table.
- **Take a backup**: a database dump, as in
[Updates](../self-hosting/getting-started.md#updates), as before any
operation on the whole table.
## Starting it
+9 -5
View File
@@ -214,11 +214,15 @@ as something to log in as.
or delete the profile under a fresh second factor. Deleting the profile
deletes everything recorded for it.
- A guardian may keep that record's settings: modules, units, thresholds,
its language and timezone, and its notification preferences. Connected
services are visible as a status only. Connect, callback, sync,
credential, disconnect, resume and test are not guardian surfaces in
this release, and their deep links land on the shared-record unavailable
page rather than on a raw refusal.
its language and timezone, and its notification preferences. A guardian
can also connect and disconnect the record's Withings, WHOOP, Oura, Polar,
Strava and Google Health from its integrations page; the data lands in the
profile's record, and a profile without its own provider app uses the
guardian's. Fitbit (no new connections), Nightscout and Dawarich cannot be
connected for a profile. Sync, the connection test and provider
credentials are not guardian surfaces: once connected, the record syncs on
its own schedule, and their deep links land on the shared-record
unavailable page rather than on a raw refusal.
- **Its reminders go to its guardians.** A record with no login has no
channels of its own, so medication reminders, measurement reminders,
safety-floor alerts and low-stock alerts fan out to every active
+5 -4
View File
@@ -86,10 +86,11 @@ backfill button in settings fetches both for the range it is given.
Since v1.42 the home, each dated location period and each stored day keep
their coarse location sealed with the instance's encryption key (one value
per row, `{ lat, lon, label }`, under the label
`healthlog/environment-location/v1`). The boot-time encryption backfill seals
rows written by older releases and clears their readable columns; the
readable columns are dropped in a later release once the backfill reports
zero rows everywhere. Key rotation (`scripts/rotate-encryption-key.ts`, or the
`healthlog/environment-location/v1`). The boot-time encryption backfill sealed
rows written by older releases and cleared their readable columns, and
migration `0409` (v1.43) dropped those columns; it refuses to run while a row
still holds a readable location
([migration-0409-plaintext-location.md](../ops/migration-0409-plaintext-location.md)). Key rotation (`scripts/rotate-encryption-key.ts`, or the
in-app rotation) covers all three.
What the person sees is unchanged: the settings page and
+29 -13
View File
@@ -9,8 +9,7 @@ already installed, longer if a TLS-fronted public hostname is in scope
- **Docker 24+ with Compose v2.** The bundled `docker-compose.yml`
pulls a multi-arch image from GitHub Container Registry (linux/amd64
- linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work
unchanged.
and linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work unchanged.
- **3 GB RAM (4 GB recommended), 10 GB free disk.** The Postgres data
volume grows with measurement history; the Apple Health import worker
can briefly hold a 1.5 GB upload in `/tmp` while it parses. The app
@@ -23,11 +22,12 @@ already installed, longer if a TLS-fronted public hostname is in scope
below the supported floor. On a busy instance, raise it.
- **Outbound HTTPS** to `ghcr.io` (image pulls) plus whichever
integration endpoints you plan to enable (Withings, OpenAI, etc.).
The opt-in environmental-context module additionally reaches
`archive-api.open-meteo.com` and `geocoding-api.open-meteo.com` for
weather and daylight; it stays off until you
enable it and set a home location, and every outbound call goes
through the SSRF-guarded egress wrapper.
The environmental-context module additionally reaches
`archive-api.open-meteo.com`, `air-quality-api.open-meteo.com` and
`geocoding-api.open-meteo.com` for weather, air quality and the city
search. It is on by default, but nothing leaves the host until someone
sets a home location, and every outbound call goes through the
SSRF-guarded egress wrapper.
Three requests happen by default without anything connected: the
HaveIBeenPwned range query when a password is set (only the first five
characters of the password's SHA-1 are sent), a daily check of the
@@ -368,6 +368,15 @@ written under that key, so remove the setting once the key is confirmed.
Your database is the only stateful piece — back it up, and back up the
key that decrypts it.
**Database dumps the app takes itself.** Every night, and before an update
applies migrations, the app writes a `pg_dump` of the whole database with a
manifest into the `backups` volume and keeps the newest few. With the
off-host bucket configured, each nightly dump is also uploaded, encrypted.
`docker compose run --rm db-restore latest` restores one into an empty
database. What is kept where, the keys a restore needs, and restoring onto a
new server: [`docs/ops/disaster-recovery.md`](../ops/disaster-recovery.md).
Keep a copy of those dumps off this host.
**Local snapshot (the common single-box case).** Dump the `db` service
with `pg_dump`:
@@ -401,8 +410,11 @@ encryption key safely (keep the old key until zero rows remain on it) see
A new image can bring database migrations, and `docker-entrypoint.sh`
applies them on the first start of that image. Most are additive, but some
rewrite data, and going back to the previous image does not undo them. The
only way back from a migration that went wrong is the dump you took before
it. So take one before every pull:
way back from a migration that went wrong is a dump taken before it. The
entrypoint takes one itself (`healthlog-<time>-pre-migration.dump` in the
`backups` volume, see
[`docs/ops/disaster-recovery.md`](../ops/disaster-recovery.md)); a dump of
your own before every pull, kept off the host, is the second copy:
```bash
# 1. Dump the database (same command as under "Backup and restore").
@@ -427,14 +439,18 @@ the `image:` line, a new major (16 to 18) is a dump and restore, described in
The start log says what is about to happen before the migrations run:
```text
HealthLog: 2 database migrations pending. Back up the database before updating (pg_dump, see the Updates section of docs/self-hosting/getting-started.md); a migration is not undone by going back to the previous image.
HealthLog: 2 database migrations pending. Unless DB_BACKUP_BEFORE_MIGRATE=false, a pg_dump of the database is written to DB_BACKUP_DIR before they run (docs/ops/disaster-recovery.md); a migration is not undone by going back to the previous image.
HealthLog: taking a database dump before 2 pending migration(s) into /var/lib/healthlog/backups...
HealthLog: pre-migration dump written: healthlog-20261025T081502Z-pre-migration.dump (212.4 MB, 231 tables, 38 s).
HealthLog: Running database migrations...
```
or `HealthLog: Database schema is up to date.` when the image brings none.
The line is a notice and does not pause the start: the migrations run right
after it. So the dump belongs before step 2, not after you see the line; by
then the database is already being migrated.
The migrations run right after the dump. A dump that fails on a writable
directory stops the start before anything is migrated, and the log names the
switch that skips it once (`DB_BACKUP_BEFORE_MIGRATE=false`). Your own dump
belongs before step 2, not after you see these lines; by then the database is
already being migrated.
If an update to v1.43 stops at migration `0409_drop_plaintext_location`,
nothing was changed; the recovery is in
+4 -4
View File
@@ -19,7 +19,7 @@ on top of it, see [building skills](../api/mcp-skills.md).
> **Read this before enabling writes.** A remote, write-capable connector
> reached over OAuth is a powerful combination. Read the
> [trust model](#trust-model-read-this-before-enabling-writes) at the end
> [trust model](#trust-model--read-this-before-enabling-writes) at the end
> of this page first.
## Prerequisites
@@ -43,7 +43,7 @@ LAN-only origin works for Claude Desktop over stdio).
The switch lives with every other module: **Settings → Modules**, the
**MCP** row. Turn it on and the `/mcp` endpoint goes live for your account.
**Settings → MCP** is where you use it. The page has three parts:
**Settings → MCP connector** is where you use it. The page has three parts:
- **State** — whether the module is on, with a link to its switch in
Modules, and your endpoint URL (`<origin>/mcp`). Turning the module off
@@ -55,7 +55,7 @@ The switch lives with every other module: **Settings → Modules**, the
## 2. Mint a connector token
Still in **Settings → MCP → Tokens**:
Still in **Settings → MCP connector → Tokens**:
1. Choose the scope with the **write** toggle:
- **off** → a read-only token (`health:read`). This is the default and
@@ -106,7 +106,7 @@ refused (`403`) as a DNS-rebinding defence.
client-id-metadata-document clients with dynamic registration as a
fallback.
3. Approve the consent screen. The connection then appears under
**Settings → MCP → Connections**, where you can revoke it.
**Settings → MCP connector → Connections**, where you can revoke it.
No token to paste — OAuth mints and rotates a short-lived `health:read`
access token (60 minutes, with a refresh token) behind the scenes.
@@ -95,7 +95,7 @@ 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 → Delete All Data** removes the rows outright, tombstones
- **Settings → Advanced → Delete all data** removes the rows outright, 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
+13 -12
View File
@@ -49,9 +49,10 @@ HealthLog behind a reverse proxy with its own domain, change App URL to that
Settings the form does not show go under Additional Environment Variables, for
example `UPDATE_CHECK_DISABLED=1`. The full list is in
[environment.md](environment.md). To use your own GeoLite2 databases, add the
directory under Additional Storage and point `GEOLITE2_DIR` at its mount path
([geolite2.md](geolite2.md)).
[`.env.production.example`](../../.env.production.example) and on the
[environment variables reference](https://docs.healthlog.dev/reference/environment-variables/).
To use your own GeoLite2 databases, add the directory under Additional Storage
and point `GEOLITE2_DIR` at its mount path ([geolite2.md](geolite2.md)).
## Plain HTTP on the LAN
@@ -207,15 +208,15 @@ before you touch it.
Until the catalog app is listed, install HealthLog with Apps, Discover Apps,
the menu next to Custom App, Install via YAML, using these values:
| Setting | Value |
| ------------ | ---------------------------------------------------------------------------------------- |
| Image | `ghcr.io/mbombeck/healthlog`, tag `X.Y.Z` (amd64 and arm64) |
| Port | `3000` (HTTP) |
| User | runs as uid `1001` by default; no extra capabilities, `no-new-privileges` is fine |
| Health check | `GET /api/health`, `200` when ready |
| Start period | at least 60 s, with more retries than the image's 5 × 30 s: migrations run at every boot |
| Database | PostgreSQL 18 (16 works too), no extensions, in its own container with its own dataset |
| Volumes | none for the app; the database gets its own dataset |
| Setting | Value |
| ------------ | -------------------------------------------------------------------------------------- |
| Image | `ghcr.io/mbombeck/healthlog`, tag `X.Y.Z` (amd64 and arm64) |
| Port | `3000` (HTTP) |
| User | runs as uid `1001` by default; no extra capabilities, `no-new-privileges` is fine |
| Health check | `GET /api/health`, `200` when ready |
| Start period | 10 min, like the image: migrations run at every boot and wait out a held lock |
| Database | PostgreSQL 18 (16 works too), no extensions, in its own container with its own dataset |
| Volumes | none for the app; the database gets its own dataset |
| Variable | Required | Notes |
| ----------------------- | -------- | ---------------------------------------------------------------------------------------- |
+2 -2
View File
@@ -116,8 +116,8 @@ PostgreSQL container you installed in Step 1. Back that one up.
`SESSION_COOKIE_SECURE=false` is required for plain-HTTP LAN installs
(`http://tower:3000`). Left at its default, the session cookie carries the
`Secure` flag, the browser refuses to send it over HTTP, and login appears to
"do nothing" — the page reloads to the login screen with no error.
`Secure` flag, which the browser would drop over HTTP. The login page says so
before anything is sent: "Signing in cannot complete on this page".
Set `SESSION_COOKIE_SECURE=true` (or leave it unset) only when a TLS reverse
proxy serves HealthLog over HTTPS. In that case also point `NEXT_PUBLIC_APP_URL`
+1 -1
View File
@@ -311,7 +311,7 @@ Tiles at the top of the dashboard live in a **single row**. Width caps
at the chart's width directly below. **Never** wrap to a second row.
If the user enables more tiles than fit, the dashboard surfaces a banner
inviting them to deactivate or reorder via Settings → Dashboard. The
inviting them to deactivate or reorder via Settings → Arrangement. The
layout editor in Settings shows a live preview of the constraint and
flags overflow before save.