Skip to content

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_PROXY so rate limits use the real client IP), but publishers on the LAN post to http://<YOUR_SERVER_IP>:88 directly so alerts survive DNS or tunnel problems.

Compose

compose.yaml
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
.env.example
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.

~/.config/ntfy/client.yml (mode 600)
# 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
~/.local/bin/ntfy-kde-notify
#!/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"
~/.config/systemd/user/ntfy-client.service
[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.


Sources & further reading