Document the new Generate keys button in notifications.md as the easiest Web Push setup path, demoting the CLI to an alternative. Note that clientManaged medication-reminder suppression is an iOS-app-only contract so a PWA-only operator does not go hunting for a web toggle. Add the VAPID env-var names and APNS_KEY_B64 to .env.production.example (the loader and APNs sender read them, but the example omitted them), both as commented placeholders, with a note that Web Push needs no Apple account and that these vars are not in the compose whitelist. Add a Backup and restore section to getting-started.md: a local pg_dump one-liner and the load-bearing warning that the ENCRYPTION_KEYS / ENCRYPTION_KEY must be backed up alongside the dump or the encrypted data is unrecoverable, cross-linking the off-host backup and key-rotation runbooks. Add troubleshooting entries for the compose env-var whitelist and pull_policy: always.
9.9 KiB
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. Three of the four channels are free, work in the browser or in a messaging app you already have, and cover every platform a self-hoster runs. APNs is only for the optional native iOS app.
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 Web Push as the universal fallback
(src/lib/notifications/dispatcher.ts). Each channel is best-effort and
independent: a user can enable one, two, or all four, and a failure on
one never blocks the others.
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 |
| APNs | An Apple Developer account + a .p8 push key |
Apple Developer Program (paid) | The native iOS app only (TestFlight / App Store) |
Web Push is the recommended default — it needs no third-party account, just a keypair you generate yourself once.
Web Push (recommended default)
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.
iPhone caveat — 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:
- Open your HealthLog instance in Safari.
- Tap the Share button, then Add to Home Screen.
- Launch HealthLog from the new Home Screen icon (not from the Safari tab).
- Open
/settings/notificationsinside 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.
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.
- In Telegram, open @BotFather, create a bot, and copy the bot
token (it looks like
123456:ABC-DEF...). - Send
/startto your new bot so it can message you. - Find your chat ID — send a message to @userinfobot, or read it from the Bot API.
- 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.
- 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). - Subscribe to that topic in the ntfy app or at
https://ntfy.sh/<topic>. - In HealthLog open
/settings/notifications, set the ntfy card's Server URL (defaults tohttps://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).
APNs — native iOS app only
Apple Push Notification service is used by only the native SwiftUI iOS app (TestFlight / App Store build). PWA and browser users never touch it. Setting it up requires a paid Apple Developer Program membership.
You do not need APNs to give iPhone users push: the installed PWA gets every reminder over Web Push (see the iPhone caveat above). APNs adds one Apple-platform exclusive the PWA cannot offer — the lock-screen Live Activity — plus the tighter native delivery path. Reminders themselves arrive on both.
If you do run the native app and want server-driven push, set the APNs
manifest (src/lib/notifications/senders/apns.ts) — three identifiers
plus exactly one key source:
| Variable | Purpose |
|---|---|
APNS_KEY_ID |
The .p8 key's Key ID |
APNS_TEAM_ID |
Your Apple Developer Team ID |
APNS_BUNDLE_ID |
The 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 is all-or-none: either set the three IDs plus 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.
Two gotchas worth knowing before you blame a stale token:
- Base64 the key. Pasting a raw
.p8PEM 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.
clientManagedis iOS-app-only. The native iOS app can opt to run its own local medication reminders and ask the server to stop sending the duplicateMEDICATION_REMINDERpush (theclientManagedflag onPATCH /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 work; the lock-screen Live Activity needs the native app. |
| You want the native iOS app's full experience | APNs (paid Apple Developer account) plus 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. |
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.