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 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.
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.
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:
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:
./scripts/restore.sh backups/notizarr-db-<timestamp>.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):
# configuration.yaml
rest_command:
notizarr:
url: "http://<lan-ip>: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('') }}"
}
# 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:
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: minstores 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.
Migrating from ntfy
docs/migration-ntfy.md — endpoint mapping,
before/after examples, what's intentionally not replicated. Short version:
point existing ntfy.sh/<topic> publishers at
http://<lan-ip>:8081/<topic> 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 --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. - 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,curlthe 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/ — 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