ntfy¶
ntfy is a simple HTTP pub/sub notification server: anything that can
curl can send a push notification to a phone or desktop. It's the single delivery point
for every alert in the lab (see the overview).
Self-hosting it means alerts don't depend on a third-party service, topics aren't guessable public names, and I control who can read and write.
How it's set up¶
- Self-hosted, auth required.
auth-default-access: deny-all: anonymous users can't read or write anything. Web app login is required. - Users, tokens and ACLs are declared in the compose file (ntfy's declarative provisioning), so they live in git and are re-applied on every start. Only the token values are secrets.
- One write-only user per publisher, each allowed only on the topics it needs.
- One read-only user for the desktop client. My own admin user reads everything from the phone app.
- Publisher users have no usable password. Their bcrypt hash is of a long random string that was never written down, so they can only authenticate with their token.
- Behind the reverse proxy for the phone app (
NTFY_BEHIND_PROXYso rate limits use the real client IP), but publishers on the LAN post tohttp://<YOUR_SERVER_IP>:88directly so alerts survive DNS or tunnel problems.
Compose¶
services:
ntfy:
image: binwiederhier/ntfy:v2.32.0
container_name: ntfy
command:
- serve
environment:
# Note: $ is doubled to $$ for docker compose (bcrypt hashes below)
NTFY_BASE_URL: "https://ntfy.<YOUR_DOMAIN>"
NTFY_BEHIND_PROXY: "true"
# Auth
NTFY_AUTH_FILE: "/mnt/appdata/ntfy/auth.db"
NTFY_AUTH_DEFAULT_ACCESS: "deny-all"
NTFY_ENABLE_LOGIN: "true"
NTFY_REQUIRE_LOGIN: "true"
# Attachments / cache
NTFY_ATTACHMENT_CACHE_DIR: "/mnt/appdata/ntfy/attachments"
NTFY_ATTACHMENT_EXPIRY_DURATION: "24h"
NTFY_CACHE_FILE: "/mnt/appdata/ntfy/cache/cache.db"
# Web push (browser notifications). Generate with: ntfy webpush keys
NTFY_WEB_PUSH_PUBLIC_KEY: "${NTFY_WEB_PUSH_PUBLIC_KEY}"
NTFY_WEB_PUSH_PRIVATE_KEY: "${NTFY_WEB_PUSH_PRIVATE_KEY}"
NTFY_WEB_PUSH_FILE: "/mnt/appdata/ntfy/web-push/webpush.db"
NTFY_WEB_PUSH_EMAIL_ADDRESS: "<YOUR_EMAIL>"
# Users: <name>:<bcrypt hash>:<role>. Hash with `ntfy user hash`, then double every $.
NTFY_AUTH_USERS: "<YOUR_USER>:<BCRYPT_HASH>:admin,komodo:<BCRYPT_HASH>:user,forgejo:<BCRYPT_HASH>:user,borgmatic:<BCRYPT_HASH>:user,host-publisher:<BCRYPT_HASH>:user,kuma:<BCRYPT_HASH>:user,beszel:<BCRYPT_HASH>:user,desktop:<BCRYPT_HASH>:user"
# ACLs: <user>:<topic>:<rw|ro|wo>. Publishers write-only, the desktop client read-only.
NTFY_AUTH_ACCESS: "komodo:alerts:wo,komodo:updates:wo,forgejo:updates:wo,borgmatic:backups:wo,borgmatic:alerts:wo,host-publisher:hosts:wo,host-publisher:alerts:wo,host-publisher:backups:wo,host-publisher:updates:wo,kuma:alerts:wo,beszel:alerts:wo,desktop:alerts:ro,desktop:updates:ro,desktop:backups:ro,desktop:hosts:ro"
# Tokens: <user>:<tk_...>:<label>. Generate with `ntfy token generate`.
NTFY_AUTH_TOKENS: "komodo:${NTFY_TOKEN_KOMODO}:Komodo alerter,forgejo:${NTFY_TOKEN_FORGEJO}:Forgejo webhooks,borgmatic:${NTFY_TOKEN_BORGMATIC}:borgmatic hooks,host-publisher:${NTFY_TOKEN_HOSTAGENT}:host scripts,kuma:${NTFY_TOKEN_KUMA}:Uptime Kuma,beszel:${NTFY_TOKEN_BESZEL}:Beszel,desktop:${NTFY_TOKEN_DESKTOP}:desktop client"
TZ: <TZ>
user: <PUID>:<PGID>
volumes:
- /mnt/appdata/ntfy:/mnt/appdata/ntfy
- /mnt/appdata/ntfy/attachments:/mnt/appdata/ntfy/attachments
- /mnt/appdata/ntfy/cache:/mnt/appdata/ntfy/cache
- /mnt/appdata/ntfy/web-push:/mnt/appdata/ntfy/web-push
ports:
- 88:80
networks:
- default
- tunnel-net
healthcheck:
test: ["CMD-SHELL", "wget -q --tries=1 http://localhost:80/v1/health -O - | grep -Eo '\"healthy\"\\s*:\\s*true' || exit 1"]
interval: 60s
timeout: 10s
retries: 3
start_period: 40s
restart: unless-stopped
init: true # needed with a healthcheck: prevents zombie processes
networks:
tunnel-net:
external: true
NTFY_WEB_PUSH_PUBLIC_KEY=<from `ntfy webpush keys`>
NTFY_WEB_PUSH_PRIVATE_KEY=<from `ntfy webpush keys`>
# One token per publisher: tk_ + 29 characters (32 total). `docker exec ntfy ntfy token generate`
NTFY_TOKEN_KOMODO=tk_...
NTFY_TOKEN_FORGEJO=tk_...
NTFY_TOKEN_BORGMATIC=tk_...
NTFY_TOKEN_HOSTAGENT=tk_...
NTFY_TOKEN_KUMA=tk_...
NTFY_TOKEN_BESZEL=tk_...
NTFY_TOKEN_DESKTOP=tk_...
Why tunnel-net: it's the shared external Docker network the reverse proxy, the
tunnel connector and the deployment tool already sit on. Joining it lets the deploy
tool's alerter post straight to http://ntfy:80 over the Docker bridge: no DNS lookup,
no hairpin out through the tunnel, no host port. So the alert path still works when DNS
or the WAN is the thing that broke. default stays first so the published port behaves
as before.
Email notifications are off. If you want them, add the NTFY_SMTP_SENDER_*
settings with an app password passed in as a secret, not written into the file.
Users, tokens and topics¶
| User | Role | Access | Used by |
|---|---|---|---|
<YOUR_USER> |
admin | everything | phone app |
komodo |
user | alerts, updates write-only |
deploy tool alerters |
forgejo |
user | updates write-only |
Git server webhooks (push to main, PR opened/closed) |
borgmatic |
user | backups, alerts write-only |
borgmatic ntfy: hook + failure-streak escalation |
host-publisher |
user | hosts, alerts, backups, updates write-only |
host scripts (SMART, disk space, reboots, apt, SnapRAID) |
kuma |
user | alerts write-only |
Uptime Kuma notification |
beszel |
user | alerts write-only |
Beszel alerts |
desktop |
user | all four topics read-only | desktop subscriber |
Write-only means a leaked publisher token can post junk to its own topics, but can't read anything or touch other topics. Rotating one is cheap: generate a new token, update that one secret, redeploy ntfy, then update the publisher.
Provisioned vs CLI users
ntfy only manages users and ACLs it provisioned from the config. Users created
earlier with ntfy user add (for example a camera NVR's publisher) are left alone on
boot. Be aware that removing a provisioned user from the config deletes it on the
next restart.
Publishing¶
Every publisher sends a bearer token. From a script:
curl -fsS -H "Authorization: Bearer $TOKEN" \
-H "Title: [$(hostname -s)] Disk space warning: /mnt/appdata" \
-H "Priority: 4" -H "Tags: warning,floppy_disk" \
-d "/mnt/appdata: 87% used" \
http://<YOUR_SERVER_IP>:88/alerts
On the hosts this is wrapped in a small helper that tries the LAN address first, then the public URL, and never fails its caller. See Host maintenance.
Uptime Kuma, Beszel and borgmatic have native ntfy support: give them the LAN URL, the topic and their access token.
Phone app¶
ntfy app → settings → add your server https://ntfy.<YOUR_DOMAIN>, log in with your
admin user, then subscribe to alerts, updates, backups and hosts. Set alerts to
the highest priority / always notify, and leave the others quiet.
Desktop client → KDE notifications¶
On the workstation the ntfy CLI runs as a systemd user service and turns every message
on the four topics into a Plasma notification via notify-send, so history ends up in the
notification bell.
# LAN address so alerts don't depend on DNS or the tunnel
default-host: http://<YOUR_SERVER_IP>:88
default-token: tk_... # the read-only `desktop` user's token
default-command: '/home/<YOUR_USER>/.local/bin/ntfy-kde-notify'
subscribe:
- topic: alerts
- topic: hosts
- topic: backups
- topic: updates
#!/bin/sh
# Called by `ntfy subscribe --from-config` once per message.
# ntfy priority 4-5 -> critical (sticks until dismissed), 3 -> normal, 1-2 -> low.
case "$NTFY_PRIORITY" in
4|5) urgency=critical; icon=dialog-error ;;
1|2) urgency=low; icon=dialog-information ;;
*) urgency=normal; icon=dialog-information ;;
esac
case "$NTFY_TOPIC" in
alerts) [ "$urgency" = critical ] || icon=dialog-warning ;;
backups) icon=backup ;;
updates) icon=system-software-update ;;
hosts) icon=computer ;;
esac
title="${NTFY_TITLE:-$NTFY_TOPIC}"
exec notify-send --app-name="ntfy · $NTFY_TOPIC" --icon="$icon" --urgency="$urgency" \
--category=network --hint=string:desktop-entry:ntfy "$title" "$NTFY_MESSAGE"
[Unit]
Description=ntfy subscriber (alerts -> KDE notifications)
Documentation=https://docs.ntfy.sh/subscribe/cli/
After=graphical-session.target network-online.target
PartOf=graphical-session.target
[Service]
ExecStart=%h/.local/bin/ntfy subscribe --config %h/.config/ntfy/client.yml --from-config
Restart=always
RestartSec=10
[Install]
WantedBy=graphical-session.target
sudo apt install libnotify-bin # notify-send
# ntfy CLI: the release binary, same version as the server, checked against checksums.txt
systemctl --user daemon-reload
systemctl --user enable --now ntfy-client.service
journalctl --user -u ntfy-client -f
Status at a glance
For a live status view on the desktop, the Uptime Kuma plasmoid (kuma-plasmoid) shows monitor states from Kuma's metrics endpoint using a Kuma API key kept in the keyring.