10 KiB
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/.
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 for the phase breakdown and 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=oidchas 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.
Auth modes
Set via AUTH_MODE (.env / docker-compose.yml environment): disabled,
proxy, or oidc — see 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 implemented
against the openid-client v5 API but hasn't been run against a real IdP
yet; test it once Pocket ID (or similar) is configured.
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.
Local development
Requires Node.js 22+ and pnpm (corepack enable will fetch the pinned
version automatically).
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
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, passwordnotizarr_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:
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:
HOST_PORT_WEB=28080 HOST_PORT_INGEST=28081 HOST_PORT_PG=25432 \
docker compose -f docker/docker-compose.yml up -d
Check health:
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):
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.
- 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):
docker build -f apps/server/Dockerfile --target runtime -t notizarr:0.1.0 . - On the Unraid host, create the deployment directory structure:
mkdir -p /mnt/tank/appdata/notizarr/{postgres,app,backups,logs} - Copy
docker/docker-compose.prod.ymlto/mnt/tank/appdata/notizarr/docker-compose.yml, and.env.exampleto/mnt/tank/appdata/notizarr/.env— fill in real values (DB password, OIDC settings,PUBLIC_BASE_URL, and especiallyBIND_ADDR_INGEST, which defaults to127.0.0.1until you explicitly set it to your LAN IP). - From
/mnt/tank/appdata/notizarr: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), so backing up the database is a complete backup.
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. Restore
with:
./scripts/restore.sh backups/notizarr-db-<timestamp>.sql.gz
This is destructive (replaces all current data) and asks for confirmation.
Consider scheduling backup.sh via an Unraid User Scripts cron entry for
regular backups.
Migrating from ntfy / integrating other services
- docs/migration-ntfy.md — endpoint mapping, before/after examples, what's intentionally not replicated
- docs/home-assistant.md —
rest_commandexample replacingntfy.publish, plus Seerr, Uptime Kuma, and generic webhook configuration
Troubleshooting
- A
docker compose up -d --builddidn't pick up a dependency change. Anonymousnode_modulesvolumes are reused by default — add-V --force-recreate(see Local development via Docker). - A mutating API call returns
csrf_check_failed. The web app must sendX-Requested-With: notizarron everyPOST/PATCH/DELETE(apps/web/src/lib/api.tsdoes this automatically for the built-in UI — this only bites hand-rolledcurltesting). If the error is specifically"origin mismatch", the browser'sOriginheader didn't match the request's ownHostheader — 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 plainhttp://<lan-ip>:portorigin 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=disabledrefuses to start. This is intentional whenNODE_ENV=production— that mode grants every request implicit admin access and is dev/test-only (seeapps/server/src/index.ts). Useoidcorproxyfor 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
userstable. 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.
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/ — architecture, threat model, database model, API design, PWA/Web Push design, Docker/Unraid design, implementation plan
- docs/migration-ntfy.md, docs/home-assistant.md — migration guides