# Notizarr Self-hosted notification hub — replaces ntfy for a small homelab, with a polished web UI/PWA, Web Push, per-topic push control, and scoped API tokens for LAN publishers. Full design rationale lives in [docs/phase1/](docs/phase1/00-README.md). ## Why Started on Telegram for home notifications, but got tired of being fully dependent on a third party and routing everything through Telegram's servers. Moved to [ntfy](https://ntfy.sh) next — better, but self-hosting it still runs into its default 250-messages/24h rate limit unless you tune it, and it's a general-purpose tool built for a much broader audience than "one household's homelab." That gap is what led to building Notizarr: something purpose-fit for this specific use case, entirely self-hosted, with no data ever leaving the LAN except the push notification itself. Status: **Phases 2–10 complete**. Auth, topics, notification ingestion (canonical + ntfy-compat + webhook), rate limiting, dedup, SSE live updates, retention cleanup, the full user-facing UI, Web Push, and the admin UI are all built and verified against a live dev stack. Phase 10's specific test gaps (authorization matrix across roles, stale push subscription cleanup, retention's global-default fallback) are covered by the automated suite (`pnpm test`), and the dependency/CSP/HSTS security pass is done — see [Backup and restore](#backup-and-restore) and the troubleshooting entries below for the operational side. See [docs/phase1/08-implementation-plan.md](docs/phase1/08-implementation-plan.md) for the phase breakdown and [MVP acceptance status](#mvp-acceptance-status) below for where things stand against the original 22 criteria. > **Note:** this was written in an environment without Node.js or Docker > installed, so none of it is executed here — it's been verified > incrementally against a real dev stack running on the target Unraid box > instead. `AUTH_MODE=oidc` has since been tested end-to-end against a real > Pocket ID instance over real HTTPS (via Pangolin), and Web Push has been > confirmed delivering to an installed iPhone PWA — see "Auth modes" and > [docs/phase1/05-pwa-web-push-design.md](docs/phase1/05-pwa-web-push-design.md). ## Auth modes Set via `AUTH_MODE` (`.env` / `docker-compose.yml` environment): `disabled`, `proxy`, or `oidc` — see [docs/phase1/02-threat-model.md](docs/phase1/02-threat-model.md). `disabled` refuses to start when `NODE_ENV=production` (it grants every request implicit admin access — dev/test only). `oidc` is confirmed working end-to-end against a real Pocket ID instance (Authorization Code + PKCE via `openid-client` v5). Once `AUTH_MODE=oidc` is set and the server restarted, the actual provider details (issuer URL, client ID/secret, redirect URL, scopes) are editable any time from **Admin → Auth** — no further env file edits or restarts needed for those. ### Setting up OIDC with Pocket ID Notizarr handles the OIDC login flow itself (Authorization Code + PKCE) — it does not delegate auth to the reverse proxy via trusted headers, so Pangolin (or whatever sits in front of it) only needs to forward HTTP, no auth injection. 1. In Pocket ID, create a new OIDC client: - **Name**: `Notizarr` - **Client's start URL**: your public Notizarr URL (e.g. `https://notify.example.com`) - **Callback URLs**: `https://notify.example.com/api/v1/auth/oidc/callback` — this exact path is hardcoded server-side ([apps/server/src/modules/auth/routes.ts](apps/server/src/modules/auth/routes.ts)), it has to match exactly - **Public Client**: **off** — Notizarr is a confidential/server-side client and needs a client secret. Leaving this on means Pocket ID never issues one. - **PKCE**: **on** — Notizarr always sends a PKCE challenge regardless, so this just means Pocket ID requires it too - Everything else (logout callback URLs, skip consent, logo) is optional/cosmetic 2. Save, then open the client back up and find its **Referenser** / Credentials tab — client secrets in Pocket ID live there, not on the creation form, and are only shown once when generated. 3. In Notizarr, go to **Admin → Auth** and fill in the Issuer URL, Client ID, Client Secret, Redirect URL (same value as the Callback URL above), and Scopes (`openid profile email`). Save. 4. Set `AUTH_MODE=oidc` in `.env` and restart the container — this one setting is the only part that still needs a restart, since it changes which routes the server exposes at boot. Everything from step 3 takes effect immediately without one. 5. Log in once. If you'd previously been running `AUTH_MODE=disabled`, its implicit "Local Admin" user already claimed the bootstrap-admin slot (the very first user *ever created*), so your real login likely comes back as a normal user — see the "First-ever login should be an admin but isn't" entry under [Troubleshooting](#troubleshooting) to fix that. **Pangolin**: point it at the web listener only (`notizarr:8080` on the same Docker network, or the host's `PUBLIC_BASE_URL`) — never at `:8081`, the LAN-only ingestion listener. See [Unraid deployment](#unraid-deployment-production) below. All mutating requests on the web listener (`POST`/`PUT`/`PATCH`/`DELETE` under `/api/`) require a `X-Requested-With: notizarr` header — this is the CSRF defense described in the threat model, not optional. The ingestion listener (`:8081`) instead requires `Authorization: Bearer nzr_...` — create a token via Settings → Admin → API Tokens. ## Repo layout ``` apps/server/ Node.js + TypeScript backend (Fastify), two listeners apps/web/ React + TypeScript + Vite frontend, installable PWA packages/shared/ Reserved for cross-package types (unused so far) docker/ docker-compose.yml (dev) and docker-compose.prod.yml (Unraid template) scripts/ backup.sh / restore.sh for the Postgres database docs/phase1/ Architecture, threat model, DB/API/PWA design, implementation plan docs/ Migration guides (ntfy, Home Assistant/Seerr/Uptime Kuma) ``` Full breakdown: [docs/phase1/07-folder-structure.md](docs/phase1/07-folder-structure.md). ## Local development Requires Node.js 22+ and pnpm (`corepack enable` will fetch the pinned version automatically). ```bash pnpm install pnpm dev ``` This runs the web app in `vite build --watch` and the server via `tsx watch` concurrently. The server serves the built web assets itself — open http://localhost:8080. `PORT_INGEST` (8081) is the LAN ingestion listener. Needs a local Postgres — either run one yourself and set `DATABASE_URL`, or use the dev Docker Compose stack below, which includes one. ## Local development via Docker ```bash docker compose --env-file .env -f docker/docker-compose.yml up -d ``` Brings up `notizarr-postgres-dev` and `notizarr-dev` (hot-reload: the repo is bind-mounted in). Default host ports (deliberately not 8080/8081/5432, since those are commonly already taken on an Unraid box): - Web UI: http://localhost:18080 - Ingestion listener: http://localhost:18081 - Postgres: `127.0.0.1:15432` (`psql -h localhost -p 15432 -U notizarr notizarr`, password `notizarr_dev_password`) Whenever a `package.json` dependency changes, rebuild **and** renew the anonymous `node_modules` volumes in one go, or the container keeps running against the old, pre-change `node_modules`: ```bash docker compose --env-file .env -f docker/docker-compose.yml up -d --build --force-recreate -V ``` Override host ports if they also collide, either inline or via a `docker/.env` file: ```bash HOST_PORT_WEB=28080 HOST_PORT_INGEST=28081 HOST_PORT_PG=25432 \ docker compose --env-file .env -f docker/docker-compose.yml up -d ``` Check health: ```bash curl http://localhost:18080/health curl http://localhost:18080/ready # 503 until Postgres is reachable curl http://localhost:18081/health ``` Generate the PWA icon set once (needs `sharp`, already a devDependency — this writes real PNGs into `apps/web/public/icons/`, committed to the repo rather than regenerated on every build): ```bash docker compose --env-file .env -f docker/docker-compose.yml exec notizarr pnpm --filter @notizarr/web generate-icons ``` ## Unraid deployment (production) Deployment root: `/mnt/tank/appdata/notizarr`. Full design: [docs/phase1/06-docker-unraid-design.md](docs/phase1/06-docker-unraid-design.md). 1. Build and tag the image (from a repo checkout, e.g. on the Unraid box or any machine with Docker, then push/copy it to where Unraid can pull it — or build directly on Unraid if the repo is checked out there): ```bash docker build -f apps/server/Dockerfile --target runtime -t notizarr:0.1.0 . ``` 2. On the Unraid host, create the deployment directory structure: ```bash mkdir -p /mnt/tank/appdata/notizarr/{postgres,app,backups,logs} ``` 3. Copy `docker/docker-compose.prod.yml` to `/mnt/tank/appdata/notizarr/docker-compose.yml`, and `.env.example` to `/mnt/tank/appdata/notizarr/.env` — fill in real values (DB password, OIDC settings, `PUBLIC_BASE_URL`, and especially `BIND_ADDR_INGEST`, which defaults to `127.0.0.1` until you explicitly set it to your LAN IP). 4. From `/mnt/tank/appdata/notizarr`: ```bash docker compose up -d ``` Point Pangolin at `notizarr:8080` (same Docker network, preferred) or the host's `PUBLIC_BASE_URL`. Never point it at 8081. ## Backup and restore Everything that matters — notification history, users, topics, API tokens, push subscriptions, the VAPID keypair, the session secret — lives in Postgres (see [docs/phase1/06-docker-unraid-design.md](docs/phase1/06-docker-unraid-design.md#backup--restore)), so backing up the database is a complete backup. **Automatic (no setup needed):** the app backs itself up once a day and keeps the last 14, no Unraid User Scripts cron entry required. Trigger one manually or download an existing one from **Admin → Backups**. **Manual / scripted**, e.g. for an off-box copy: ```bash cd /mnt/tank/appdata/notizarr ./scripts/backup.sh ``` Writes a timestamped, gzipped `pg_dump` plus a copy of the compose file (env vars, including the DB password and OIDC client secret, are inline there rather than in a separate `.env` — keep `backups/` off shared or public storage) into `backups/`, keeping the last 14 of each — the same directory and naming the in-app job uses, so both coexist. Restore with: ```bash ./scripts/restore.sh backups/notizarr-db-.sql.gz ``` This is destructive (replaces all current data) and asks for confirmation. ## Home Assistant Add a `rest_command` pointing at the LAN ingestion listener (`:8081`, never the public URL): ```yaml # configuration.yaml rest_command: notizarr: url: "http://:8081/api/v1/notifications" method: POST headers: Authorization: !secret notizarr_token Content-Type: "application/json" payload: > { "topic": "{{ topic }}", "title": "{{ title }}", "message": "{{ message }}", "source": "{{ source | default('home-assistant') }}", "priority": "{{ priority | default('default') }}", "image_url": "{{ image_url | default('') }}" } ``` ```yaml # secrets.yaml — the value is the FULL header including "Bearer ", since a # YAML !secret must be a key's entire value, not embedded in a larger string notizarr_token: "Bearer nzr_your_token_here" ``` Then call it from any automation: ```yaml action: rest_command.notizarr data: topic: homeassistant title: "🏠 Home" message: "Office window is open, don't forget to close it" ``` Get the token from **Admin → API Tokens → Create token**. Two extras worth knowing: - **`priority: min`** stores and shows the notification as usual but skips the push — useful for automations that should log something without buzzing anyone's phone, without needing a separate "quiet" topic. - **Quiet hours** (Admin → System) and **per-topic snooze** (the alarm-clock icon on any topic page) both suppress push independently of what any individual automation sends, if you'd rather configure "don't wake me up" once instead of per-automation. Full field reference, dedup keys, and examples for Seerr/Uptime Kuma/generic webhooks: [docs/home-assistant.md](docs/home-assistant.md). ## Migrating from ntfy [docs/migration-ntfy.md](docs/migration-ntfy.md) — endpoint mapping, before/after examples, what's intentionally not replicated. Short version: point existing `ntfy.sh/` publishers at `http://:8081/` with an `Authorization: Bearer nzr_...` header instead — headers, JSON body, and query-param publishing all still work the same way. ## Troubleshooting - **`docker compose ... exec/logs/etc` fails with `required variable POSTGRES_PASSWORD is missing a value`.** Compose's implicit `.env` lookup when the compose file is passed via `-f docker/docker-compose.yml` from the repo root doesn't reliably resolve to the repo root's `.env` — it's ambiguous/version-dependent, and got this wrong in practice. Always pass `--env-file .env` explicitly, on every invocation against this file, not just `up` (see the comment at the top of `docker/docker-compose.yml`). - **A `docker compose up -d --build` didn't pick up a dependency change.** Anonymous `node_modules` volumes are reused by default — add `-V --force-recreate` (see [Local development via Docker](#local-development-via-docker)). - **A mutating API call returns `csrf_check_failed`.** The web app must send `X-Requested-With: notizarr` on every `POST`/`PATCH`/`DELETE` (`apps/web/src/lib/api.ts` does this automatically for the built-in UI — this only bites hand-rolled `curl` testing). If the error is specifically `"origin mismatch"`, the browser's `Origin` header didn't match the request's own `Host` header — that's a real cross-origin request being correctly blocked, not a false positive. - **Push notifications don't work / diagnostics show "Service worker: Not registered".** Service workers and the Push API require a secure context — HTTPS, or `localhost`. A plain `http://:port` origin will never register a service worker; this isn't a bug, it's the browser enforcing the spec. Test push once Pangolin is serving Notizarr over real HTTPS. - **iPhone won't show push notifications even over HTTPS.** Confirm iOS 16.4+, and that Notizarr was installed via Share → Add to Home Screen — Safari does not support Web Push for ordinary browser tabs, only installed PWAs. Check Settings → Push diagnostics in the app. - **`AUTH_MODE=disabled` refuses to start.** This is intentional when `NODE_ENV=production` — that mode grants every request implicit admin access and is dev/test-only (see `apps/server/src/index.ts`). Use `oidc` or `proxy` for anything reachable off localhost. - **First-ever login should be an admin but isn't.** Bootstrap-admin only promotes the very first user *ever created* in the `users` table. If a non-admin user was created first (e.g. during testing), promote them manually via Settings → Admin → Users using an existing admin account, or directly in Postgres if none exists yet. - **A third-party app's built-in "Ntfy" notification type won't send ("test failed", 404).** Sonarr/Radarr/Prowlarr-style apps typically append each topic in their own "Topics" field to the Server URL (`{Server URL}/{topic}`), so the topic must **not** already be in the Server URL — just the bare host+port. Overseerr/Jellyseerr-style apps do the opposite: they expect the topic already in the Server URL and don't append anything, so putting a topic in a separate "Topic" field there is redundant but harmless. When in doubt, `curl` the ingestion listener directly with the same topic path the app would use — Notizarr always expects exactly one path segment (`POST /:topic`), and comparing that against what actually gets sent explains almost every case of this. ## MVP acceptance status All 22 hard acceptance criteria are built and verified against a live dev stack reachable over real HTTPS (Docker Compose, Postgres healthy, auth/topics/tokens/publish/push-toggle/PWA-install all confirmed working, history and settings survive container restarts, OIDC login against a real Pocket ID instance, and push confirmed arriving on an installed iPhone PWA — 17–18 were the last two blocked on real HTTPS and are now closed out too). ## Commands | Command | Does | |---|---| | `pnpm dev` | Run web + server in watch mode | | `pnpm build` | Production build of both | | `pnpm typecheck` | TypeScript, no emit, across all workspaces | | `pnpm lint` | ESLint across the repo | | `pnpm test` | Tests across all workspaces | ## Documentation - [docs/phase1/](docs/phase1/00-README.md) — architecture, threat model, database model, API design, PWA/Web Push design, Docker/Unraid design, implementation plan - [docs/migration-ntfy.md](docs/migration-ntfy.md), [docs/home-assistant.md](docs/home-assistant.md) — migration guides