375 lines
16 KiB
Markdown
375 lines
16 KiB
Markdown
# 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–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.
|
||
|
||
### 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-<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):
|
||
|
||
```yaml
|
||
# 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('') }}"
|
||
}
|
||
```
|
||
|
||
```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/<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
|
||
|
||
- **`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://<lan-ip>: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
|