Files
HealthLog/docs/self-hosting/notifications.md
T
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00

19 KiB
Raw Blame History

Notifications — push channels for a self-host

HealthLog delivers reminders (medication doses, mood check-ins, personal records) and a few operational alerts through a small cascade of push channels. The single message to take away: you do not need an Apple Developer account or APNs to get push. Five of the six channels are free, work in the browser or in an app you already have, and cover every platform a self-hoster runs. APNs is only for the native iOS app, and even that you can build yourself (see the last section).

When an event fires, the dispatcher fans it out to every channel a user has enabled, in a fixed order — APNs first, then Telegram, then ntfy, then the generic webhook, then email, then Web Push as the universal fallback (src/lib/notifications/dispatcher.ts). Each channel is best-effort and independent: a user can enable one or several, and a failure on one never blocks the others.

An APNs-less instance degrades gracefully. Nothing about push depends on APNs being configured. Urgent events still surface everywhere it matters: ntfy escalates them to max priority (5), Web Push raises urgency and keeps the notification on screen until the user acts, and the webhook carries the urgent flag for your own routing. APNs only adds the Apple-platform exclusives the web cannot offer.

TL;DR — which channel needs what

Channel What it needs Cost Where it works
Web Push A VAPID keypair (one command, or paste into the admin panel) Free Any modern desktop/Android browser; the installed PWA on iPhone (iOS 16.4+, after Add to Home Screen)
Telegram A bot token from @BotFather + each user's chat ID Free Anywhere the Telegram app runs
ntfy A topic on ntfy.sh or your own ntfy server Free The ntfy app (iOS/Android) or any browser
Webhook A URL you own (and optionally one custom header) Free Anywhere — you route the POST yourself
Email An SMTP transport you operate or rent Free–cheap Any mailbox
APNs An Apple Developer account + a .p8 push key + your own signed iOS build Apple Developer Program (paid) A native iOS app you build and sign

Web Push is the recommended default — it needs no third-party account, just a keypair you generate yourself once.

Web Push delivers notifications straight to a browser — desktop or mobile — even when the HealthLog tab is closed, as long as the browser or installed PWA is alive. It is the broadest-reach channel and the one to set up first.

1. Generate and store a VAPID keypair

VAPID is the signing scheme that lets a push service trust your server. You also choose a subject — a mailto: address the push service can contact about your sender, e.g. mailto:you@example.com.

The server loads VAPID config from the database first and falls back to environment variables (src/lib/notifications/vapid-config.ts). The admin panel is the easiest path because it survives without touching .env.

Admin panel — Generate keys (easiest). Sign in as the admin user, open /admin, and find the Web Push VAPID card (src/components/admin/web-push-vapid-section.tsx). Click Generate keys: the server mints a fresh keypair, stores the private key encrypted at rest with your ENCRYPTION_KEY, seeds a placeholder subject, and fills in the public key. Edit the subject to your real mailto: address and save. The card shows a "configured" badge once all three fields are set — no shell, no copy-paste.

If a keypair already exists, Generate keys asks you to confirm before replacing it. Regenerating invalidates every existing browser subscription, so each device has to re-subscribe afterwards. Only regenerate when you mean to.

Admin panel — paste an existing pair. If you already have a keypair (e.g. copied from another deployment), paste the public key, the private key, and the subject into the same card and save instead of generating.

CLI — generate a pair yourself. You can also mint one with the bundled web-push CLI and paste the result into the card or your .env:

npx web-push generate-vapid-keys

That prints a public key and a private key (both Base64URL).

2. Store the keys via environment variables (alternative)

If you would rather keep secrets out of the database, set them in .env / the compose environment: block instead:

VAPID_PUBLIC_KEY="BElx...(public key)"
VAPID_PRIVATE_KEY="...(private key)"
VAPID_SUBJECT="mailto:you@example.com"

The database values win when both are present. The loader also accepts a few aliases (WEB_PUSH_VAPID_PUBLIC_KEY, WEB_PUSH_PUBLIC_KEY, and the private/subject equivalents) so a config copied from another deployment keeps working.

3. Subscribe a device

Each user enables Web Push for their own browser from /settings/notifications — the Browser Push card has a Subscribe button, and a Test button to fire a one-off push once subscribed. The browser prompts for notification permission on first subscribe; once granted, that browser receives every enabled event.

The PWA path — install to the Home Screen

Installing HealthLog as a PWA and enabling Web Push is the recommended default for phones. It needs nothing from Apple and gives you, in v1.18.4:

  • Reminders. Medication doses, mood check-ins, and any other event the user has enabled, delivered to the installed app even when it is closed.
  • Taken → clears the reminder. When you log a dose, the server sends a silent type:"clear" push that closes the still-pending dose-due reminder for that slot (matched on its stable tag) without showing a new notification (public/sw.js). This is the PWA equivalent of ending an iOS Live Activity.
  • An app badge for outstanding doses. The icon on the Home Screen carries a server-authoritative count of doses still due; logging a dose decrements it, and a count of zero clears the badge. The count is computed on the server and reflected by the service worker via the Badging API (navigator.setAppBadge), feature-detected — engines without it (most desktop Firefox, older Safari) silently no-op.

iPhone — Add to Home Screen first

Safari on iPhone supports Web Push only from iOS 16.4 or newer, and only for a site that has been installed to the Home Screen as a PWA. A regular Safari tab cannot subscribe. So on iPhone:

  1. Open your HealthLog instance in Safari.
  2. Tap the Share button, then Add to Home Screen.
  3. Launch HealthLog from the new Home Screen icon (not from the Safari tab).
  4. Open /settings/notifications inside that installed app and tap Subscribe; accept the permission prompt.

Android and desktop browsers have no such step — Subscribe and grant the permission, and push works.

The inherent limit

The installed PWA gets reminders, the taken-clears behaviour, and the app badge — but it cannot show a native iOS lock-screen Live Activity widget. That single feature rides Apple Push Notification service and is only available through a native iOS build with APNs. Reminders themselves arrive on both paths; only the Live Activity is APNs-exclusive.

Telegram

Telegram delivers reminders as bot messages, with inline action buttons on medication reminders (Taken / snooze / skip). It is a good fit when you already live in Telegram or want push without running a browser.

  1. In Telegram, open @BotFather, create a bot, and copy the bot token (it looks like 123456:ABC-DEF...).
  2. Send /start to your new bot so it can message you.
  3. Find your chat ID — send a message to @userinfobot, or read it from the Bot API.
  4. In HealthLog open /settings/notifications, fill the Telegram card's Bot Token and Chat ID, toggle it on, and save.

One bot can serve every user on the instance; each user pairs their own chat ID. The token is encrypted at rest.

ntfy

ntfy is the most self-hoster-native option: a tiny pub/sub push relay you can use hosted (ntfy.sh) or run yourself. You subscribe to a topic in the ntfy app (iOS/Android) or a browser, and HealthLog publishes to that topic.

  1. Pick a hard-to-guess topic name (the topic is the access control on public ntfy.sh — anyone who knows the string can read it, so treat it like a secret).
  2. Subscribe to that topic in the ntfy app or at https://ntfy.sh/<topic>.
  3. In HealthLog open /settings/notifications, set the ntfy card's Server URL (defaults to https://ntfy.sh; point it at your own server if you self-host ntfy) and Topic, then save.

If your ntfy server requires auth, an access token can be attached; the server URL is validated as a public host and dialled through the egress guard (src/lib/notifications/senders/ntfy.ts). Urgent events are sent at ntfy's top priority (5 / max) so they bypass batching and surface on the lock screen.

Webhook

The generic webhook POSTs every enabled event as JSON to a URL you own — wire it into your own automation (Home Assistant, n8n, a Discord/Slack relay, whatever routes the payload). You supply a public URL and, optionally, one custom header (e.g. an auth token).

  1. In HealthLog open /settings/notifications, fill the Webhook card's URL and, if your endpoint expects one, a single header name + header value.
  2. Save. A Test button fires a one-off POST so you can confirm routing.

The URL is validated as a public host and dialled through safeFetch({ requirePublicHost: true }) — the SSRF guard blocks private and loopback ranges because the target is user-supplied (src/lib/notifications/senders/webhook.ts). The webhook carries the event's urgency flag so you can route urgent events differently.

Email (SMTP)

The email channel sends reminders to a mailbox over an SMTP transport you operate or rent. It is configured instance-wide by the operator (one transport serves every user); each user then enables the channel and sets their destination address from /settings/notifications.

Set the SMTP transport in .env / the compose environment: block:

SMTP_HOST="smtp.example.com"
SMTP_PORT="587"
SMTP_FROM="HealthLog <noreply@example.com>"
SMTP_USER=""           # optional — omit for an unauthenticated relay
SMTP_PASS=""           # optional
SMTP_SECURE="false"    # true = implicit TLS (port 465); false = STARTTLS

SMTP_HOST + SMTP_PORT + SMTP_FROM together enable the channel; SMTP_USER / SMTP_PASS are optional (omit them for an unauthenticated relay). SMTP_SECURE=true uses implicit TLS on port 465; leave it false for STARTTLS on 587. As with every var in docker-compose.yml, these must be on the compose environment: whitelist to reach the container.

APNs — native iOS, and why it needs your own Apple account

Apple Push Notification service is used by only a native SwiftUI iOS app. PWA, browser, Telegram, ntfy, webhook, and email users never touch it. It is the one channel a self-hoster cannot simply switch on, and the reason is worth stating plainly.

Why your server can't push to the published app

APNs binds a push to a specific Apple Developer team + app bundle ID + signing key. The HealthLog iOS app published on TestFlight / the App Store is signed under the maintainer's Apple team and bundle ID. Your self-hosted server has neither that team's .p8 key nor the authority to mint one, so it cannot send APNs pushes to the published app. There is no way around this — it is how Apple's trust model works, by design.

To get native iOS push and the lock-screen Live Activity widget on your own infrastructure, you have to publish your own copy of the app under your own Apple identity. Everything below is that path.

Step by step — your own native iOS build

  1. Get an Apple Developer account (~$99/yr). Required to sign an iOS app for a real device and to create a push key.
  2. Build and sign your own copy of the iOS app. The client is open in a separate repository (healthlog-iOS). Open it in Xcode and set your own bundle identifier and your team as the signing team, then build to your device (or distribute via your own TestFlight).
  3. Create your own APNs .p8 auth key in the Apple Developer Portal, under Keys → Create a Key → Apple Push Notifications service (APNs). Note the Key ID, and your Team ID (top-right of the portal). Scope the key Sandbox & Production (see the gotcha below).
  4. Point your bundle ID at your server. Build the iOS app against your instance so the device registers its APNs token with your server.
  5. Set the APNs manifest on the server. Add the env vars below to .env / the compose environment: block.

The APNs env vars (src/lib/notifications/senders/apns.ts):

Variable Purpose
APNS_KEY_ID The .p8 key's Key ID
APNS_TEAM_ID Your Apple Developer Team ID
APNS_BUNDLE_ID Your app's bundle identifier
APNS_KEY_B64 The .p8 PEM, base64-encoded (recommended key source)
APNS_KEY OR the raw PEM body inline
APNS_KEY_FILE OR a path to the .p8 file
APNS_CRITICAL_ENTITLEMENT Optional — set true only if Apple grants your app the Critical Alerts entitlement

APNs is all-or-none: either set the three IDs plus exactly one key source, or leave the whole block unset. A partial config is rejected so the channel never silently half-works (scripts/check-env.ts). When more than one key source is present, the precedence is APNS_KEY_B64 > APNS_KEY > APNS_KEY_FILE.

APNS_CRITICAL_ENTITLEMENT=true is honoured only if Apple has actually granted your app the Critical Alerts entitlement (a separate request to Apple). Without that entitlement, leave it unset — the server keeps urgent events at normal priority rather than minting a critical payload your app cannot send (src/lib/notifications/types.ts).

Two gotchas worth knowing before you blame a stale token:

  • Base64 the key. Pasting a raw .p8 PEM through some env-file pipelines mangles its line breaks. APNS_KEY_B64 (the PEM body, base64-encoded) sidesteps that entirely and is the recommended source.
  • Scope the key "Sandbox & Production". In the Apple Developer Portal, a push key defaults to Sandbox-only. A Sandbox-only key fails on production (TestFlight / App Store) device tokens with BadEnvironmentKeyInToken. Confirm the key shows Sandbox & Production; a wrongly scoped key cannot be reconfigured — re-issue it.

clientManaged is iOS-app-only. A native iOS app can opt to run its own local medication reminders and ask the server to stop sending the duplicate MEDICATION_REMINDER push (the clientManaged flag on PATCH /api/auth/me/notification-prefs). This is a native-app contract: a web/PWA-only self-hoster never needs it and there is no toggle for it in the web UI. If you only run the PWA, ignore it entirely — your medication reminders come from the server cron over the channels above.

Which channel should I pick?

Your setup Recommended channel
PWA on Android or desktop Web Push — generate a VAPID keypair, subscribe, done.
PWA on an iPhone Web Push, but install the PWA via Add to Home Screen first (iOS 16.4+). Reminders, taken-clears, and the app badge all work; the lock-screen Live Activity needs a native build.
You want the native iOS lock-screen Live Activity Build and sign your own iOS app under your own Apple account and wire up APNs (paid), with Web Push as a fallback for any non-iOS device.
Headless / no browser, or you live in chat Telegram or ntfy — neither needs a browser, both are free. ntfy is the most self-host-native; Telegram adds inline reminder action buttons.
You already run an automation hub Webhook — POST every event to your own URL and route it however you like.
You want reminders in your inbox Email — set the SMTP transport once as the operator; users add their address.

Mixing channels is fine and common — e.g. Web Push on the laptop and ntfy on the phone. Each user configures their own set from /settings/notifications.