Files
HealthLog/docs/self-hosting/truenas.md
T
Marc-André Bombeck 2c295bbea8 Probe the oldest stored values before recording an encryption key
The boot key check recorded whatever key was configured whenever its probe
found nothing to try. It only read string columns, so an install whose
ciphertext sat in Bytes columns (notes, documents, ECG, symptoms) read as
empty; a probe that ran out of time did the same; and rows written by a
process that served under a wrong key could open on the next boot and get
that key recorded as the right one.

The probe now reads every registered column, string and Bytes, in all four
stored layouts, and tries the oldest values first, ordered by the row's
updatedAt (else createdAt, else primary key). A key is recorded only when
the oldest value that opens is preceded by at most one that does not, or
when no column holds a single value under the key id. A time-out, an
unreadable table, oversized values or mixed results are inconclusive: the
process serves, records nothing and checks again at the next start.

When the first insert loses a race, the stored record is read back and must
open under this process's key, else the outcome is a mismatch.

A rotation that walked every column without errors removes the record of
each previous key id that no longer holds any value. The refusal log block
and the self-hosting pages explain how to reset the check for a key id.
2026-10-03 23:29:55 +02:00

6.1 KiB
Raw Blame History

HealthLog on TrueNAS SCALE

HealthLog runs on TrueNAS SCALE as a Docker app: one app container from ghcr.io/mbombeck/healthlog and one PostgreSQL 16 database. The app container is stateless; everything you care about lives in the database dataset and in one value you must keep yourself: the encryption key.

What the app needs

Setting Value
Image ghcr.io/mbombeck/healthlog, tag X.Y.Z (amd64 and arm64)
Port 3000 (HTTP)
User runs as uid 1001; 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 3 × 30 s: migrations run at every boot
Database PostgreSQL 16 (17 and later are not a drop-in), no extensions
Volumes none for the app; the database gets its own dataset

Environment

Variable Required Notes
DATABASE_URL yes postgresql://<user>:<password>@<host>:5432/<db>
ENCRYPTION_KEY yes 64 hex characters: openssl rand -hex 32. Keep a copy outside the NAS (see below).
API_TOKEN_HMAC_KEY yes 64 hex characters, generated the same way. Changing it signs everyone out.
NEXT_PUBLIC_APP_URL yes the address people open, for example http://truenas.local:30180
APP_URL yes the same address
SESSION_COOKIE_SECURE no false for a LAN install served over plain http://; leave unset behind an HTTPS proxy
HEALTHLOG_PLATFORM no truenas: the admin's key backup step opens on the TrueNAS instructions
GEOLITE2_DIR no a mounted directory of GeoLite2 .mmdb files, so sign-in locations are resolved on the NAS

HEALTHLOG_PROCESS_TYPE stays unset (all): one container serves the web app and runs the background jobs.

Plain HTTP on the LAN

Most NAS installs are reached over http://. Set SESSION_COOKIE_SECURE=false for that. If it is left at its default, the browser drops the sign-in cookie and the login page says so before you type a password: "This server only sets its sign-in cookie over https://".

Back up your encryption key

HealthLog encrypts tokens, notes, documents and in-database backups with ENCRYPTION_KEY. The key is not stored in the database. If the app is deleted and reinstalled with a new key over the old database dataset, the data is still there but cannot be read.

  1. Copy ENCRYPTION_KEY from the app's settings (Apps, HealthLog, Edit, Environment) into your password manager.

  2. Sign in as the admin and open Admin, Encryption. The step shows the key id and a fingerprint, the first 12 hex characters of SHA-256 over the key. Compare it with your copy:

    echo -n "<your key>" | xxd -r -p | sha256sum | cut -c1-12
    

    or paste the copy into "Check my copy" (it is compared and dropped, never stored or logged).

  3. Confirm the backup. Until you do, admins see a reminder on the dashboard. After a key rotation the step comes back for the new key.

When the key does not match the database

At every start HealthLog checks that its key opens the data already in the database. If it does not, it refuses to serve instead of failing on every encrypted value: pages show "HealthLog cannot open its data", API requests answer 503 with meta.errorCode encryption.key_mismatch, /api/health answers 503 with reason: "encryption_key_mismatch", and no background jobs run. TrueNAS shows the app as unhealthy rather than crashed, so the reason stays visible.

Fix it one of three ways, then restart the app:

  1. Put the original ENCRYPTION_KEY back.
  2. Point DATABASE_URL at the database that belongs to this key.
  3. Start fresh with an empty database. The old data and its backups need the old key.

Before it records a key as the right one, the check reads the oldest encrypted values in the database and records the key only if they open (or if the database holds no encrypted value under that key id yet). When the data proves neither, the app keeps serving, records nothing, writes a warning starting with [boot] Encryption key check inconclusive to the log, and checks again at the next start.

Resetting the check

If you are certain the configured key is the one the data was written with, and the app still refuses because the check recorded a different key earlier, remove that record and restart. Open a shell on the database container (Apps, HealthLog, the database container's Shell) and run, with the key id the log names (v1 unless you use ENCRYPTION_KEYS):

psql -U healthlog healthlog \
  -c "DELETE FROM encryption_key_canaries WHERE key_id = 'v1';"

Then restart the app. It probes the stored data again and records the key only if that data opens with it. If the key is in fact wrong, the app then fails on every encrypted value instead of refusing clearly, so only do this when you are sure. After a key rotation you never need this for the old key id: the rotation script removes its record once no values remain under it.

Updates

Pick the new tag, take a snapshot of the database dataset first, and update. Migrations run on start; the first start of a new install applies all of them, which is why the start period above is generous.