# 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). Status: **Phases 2–9 complete**, Phase 10 (hardening/test-gap-filling) in progress. 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. 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. 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 -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 -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 -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 -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 - **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