What you will set up
ntfy (say "notify") turns a plain HTTP request into a notification on your phone or in your browser. A script, a backup job or a monitoring tool sends a line of text to a topic, such as alerts-server, and every device subscribed to that topic shows it within a second. Anything that can run curl or call a webhook can use it.
ntfy is developed by Philipp C. Heckel as an open source project on GitHub, dual licensed under the Apache License 2.0 and the GNU General Public License 2.0 (GPLv2). The public server ntfy.sh, which the apps use unless you add your own, is run by ntfy LLC, whose terms are governed by the law of Connecticut, United States. Your own server contacts no outside service by default: a capture of its traffic during start-up and while publishing showed no connections at all. The one exception you may switch on is for iPhones, in step 11, and it is described there.
Here it runs from its official image, in Podman under a user of its own called ntfy, behind Caddy from the Podman guide. Nobody can read or send anything without an account: one account publishes, another reads.
Every step below was run on a fresh Melonslab VC-P Alloy (2 vCPU, 8 GB) with Debian 13:
- ntfy 2.28.0 answered through Caddy with a Let's Encrypt certificate. Without an account, publishing, reading and opening a WebSocket all got "403 forbidden" through Caddy.
- A token that may only publish could not read, and could not publish to topics outside its own. The reading account could not publish.
- Messages from
curl, from an SSH login, from a failed systemd service, from Uptime Kuma 2 and from a Grafana webhook payload arrived on the subscribed topic, in a browser and in three streaming connections held open through Caddy for almost two minutes. - ntfy saw each visitor's real address, over IPv4 and IPv6, and ignored a forged
X-Forwarded-Forheader. Rate limits answered "429" after a burst of requests and after repeated wrong passwords. - Users, access rules and tokens were restored from a backup into an empty volume, and everything came back by itself after a reboot, with cached messages kept.
ntfy used about 18 MB of memory.
Before you start
You need:
- a server set up as in the Podman guide, with Caddy running;
- an A record and an AAAA record for
ntfy.example.compointing at your server; - for the SSH and systemd alerts,
curlon the server:apt install -y curl.
The examples use ntfy.example.com for ntfy and 203.0.113.10 for your server. Replace them throughout.
1. Create the user
As root:
useradd -m -s /bin/bash ntfy
loginctl enable-linger ntfy
machinectl shell ntfy@
Everything up to step 4 runs as ntfy.
2. Write the configuration
mkdir -p ~/ntfy ~/.config/containers/systemd
Create ~/ntfy/server.yml:
base-url: "https://ntfy.example.com"
listen-http: ":80"
# Caddy passes on each visitor's address in X-Forwarded-For.
behind-proxy: true
# Keep messages for a day, on disk, so that phones that were offline get them later.
cache-file: "/var/lib/ntfy/cache.db"
cache-duration: "24h"
# Nobody gets in without an account, and accounts only reach the topics you allow.
auth-file: "/var/lib/ntfy/user.db"
auth-default-access: "deny-all"
enable-login: true
What the lines do:
behind-proxy: truemakes ntfy take the visitor's address from theX-Forwarded-Forheader that Caddy sets. Without it, every request seems to come from the server's own IPv4 address,203.0.113.10, because that is how pasta passes on Caddy's connections, and all visitors then share one set of rate limits. Caddy replaces anyX-Forwarded-Fora visitor sends, so the header cannot be forged from outside.cache-fileandcache-durationkeep each message for 24 hours, in a small SQLite file. A phone that was offline fetches what it missed when it reconnects. Withoutcache-file, the cache is in memory only and is lost when ntfy restarts.cache-duration: "0"keeps nothing at all.auth-default-access: "deny-all"closes every topic to anyone without an account. ntfy's default isread-write, which lets anyone on the internet read and write any topic on your server.enable-login: truelets you sign in to the web app, in step 10.
Attachments stay off: ntfy only accepts files when attachment-cache-dir is set, and answers "attachments not allowed" otherwise. The same goes for a message body over 4 KB, which ntfy would otherwise turn into an attachment. Alerts are short, so there is nothing to store.
3. Define the container
Create ~/.config/containers/systemd/ntfy.container:
[Unit]
Description=ntfy
[Container]
ContainerName=ntfy
Image=docker.io/binwiederhier/ntfy:v2
Exec=serve
Volume=%h/ntfy/server.yml:/etc/ntfy/server.yml:ro
Volume=ntfy-data:/var/lib/ntfy
# Only Caddy, on this server, can reach ntfy: the port is not open to the internet.
PublishPort=127.0.0.1:8107:80
AutoUpdate=registry
[Service]
Restart=always
[Install]
WantedBy=default.target
The v2 tag follows every 2.x release. Start it:
systemctl --user daemon-reload
systemctl --user start ntfy
systemctl --user enable --now podman-auto-update.timer
curl -s http://127.0.0.1:8107/v1/health
The last command prints {"healthy":true}.
4. Put Caddy in front
Go back to root with exit, switch to machinectl shell caddy@, and add this block at the end of ~/Caddyfile:
ntfy.example.com {
reverse_proxy 127.0.0.1:8107
}
Restart Caddy with systemctl --user restart caddy.
Phones and browsers keep a connection open to ntfy and wait for messages, over a WebSocket or a long HTTP stream. Caddy passes both on without extra settings, and ntfy sends a keepalive every 45 seconds so that nothing between closes an idle connection.
5. Create accounts and access rules
The examples use two accounts:
alertspublishes. Scripts, Uptime Kuma and Grafana use its token, never a password.annareads. You use it in the browser and on your phone.
Both get the topics that start with alerts-, such as alerts-server and alerts-kuma, and nothing else. As root, switch to machinectl shell ntfy@, and create them. Each asks for a password twice:
podman exec -it ntfy ntfy user add anna
podman exec -it ntfy ntfy user add alerts
podman exec ntfy ntfy access anna 'alerts-*' read-only
podman exec ntfy ntfy access alerts 'alerts-*' write-only
podman exec ntfy ntfy token add --label=scripts alerts
The last command prints the token, which starts with tk_. Keep it: it is what your scripts send. Check the rules:
podman exec ntfy ntfy access
user alerts (role: user, tier: none)
- write-only access to topic alerts-*
user anna (role: user, tier: none)
- read-only access to topic alerts-*
user * (role: anonymous, tier: none)
- no topic-specific permissions
- no access to any (other) topics (server config)
A token can do everything its account can, so a token for alerts can only publish to alerts-*. If one leaks, delete it with podman exec ntfy ntfy token remove alerts tk_..., and create a new one. ntfy token list shows each token with the address it was last used from.
6. Send a notification
From any computer, with your token in place of tk_...:
curl -H "Authorization: Bearer tk_..." \
-H "Title: Disk almost full" \
-H "Priority: high" \
-H "Tags: warning" \
-d "/var is at 91%" \
https://ntfy.example.com/alerts-server
ntfy answers with the message as JSON. The headers are optional:
Priorityis one ofmin,low,default,highandurgent, or 1 to 5. Phones can play a different sound for each, and the web app can hide the low ones.Tagstakes a comma-separated list. Tags that are names of emoji, such aswarning,keyorrotating_light, are shown as the emoji in front of the title.
Without a token, ntfy refuses, for publishing and reading alike:
curl -d "hello" https://ntfy.example.com/alerts-server
{"code":40301,"http":403,"error":"forbidden","link":"https://ntfy.sh/docs/publish/#authentication"}
7. Get an alert when someone logs in over SSH
The token lives in one file that only root can read. As root, with your token:
install -m 600 /dev/null /etc/ntfy.env
cat > /etc/ntfy.env <<'EOF'
NTFY_URL=https://ntfy.example.com/alerts-server
NTFY_TOKEN=tk_...
EOF
Create /usr/local/sbin/ntfy-ssh-login:
#!/bin/sh
# Called by PAM for every SSH session; only act when one opens.
[ "$PAM_TYPE" = "open_session" ] || exit 0
. /etc/ntfy.env
curl -s -m 5 -o /dev/null \
-H "Authorization: Bearer $NTFY_TOKEN" \
-H "Title: SSH login on $(hostname)" \
-H "Tags: key" \
-d "$PAM_USER logged in from $PAM_RHOST" \
"$NTFY_URL" &
exit 0
Make it executable, and have SSH run it for every login:
chmod 755 /usr/local/sbin/ntfy-ssh-login
echo "session optional pam_exec.so /usr/local/sbin/ntfy-ssh-login" >> /etc/pam.d/sshd
Log in from another terminal. A message such as root logged in from 198.51.100.7 arrives. The optional and the & mean that a login never waits for ntfy, or fails because of it: with ntfy stopped, our test login still took 1.3 seconds.
8. Get an alert when a service fails
systemd can start another unit when one fails, with OnFailure=. Create /usr/local/sbin/ntfy-unit-failed, which sends the failed unit's status and its last log lines:
#!/bin/sh
. /etc/ntfy.env
systemctl status --no-pager --lines=5 "$1" | curl -s -m 10 -o /dev/null \
-H "Authorization: Bearer $NTFY_TOKEN" \
-H "Title: $1 failed on $(hostname)" \
-H "Priority: high" \
-H "Tags: rotating_light" \
--data-binary @- \
"$NTFY_URL"
And a template unit that runs it, /etc/systemd/system/ntfy-failed@.service:
[Unit]
Description=Send an ntfy alert that %i failed
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/ntfy-unit-failed %i
chmod 755 /usr/local/sbin/ntfy-unit-failed
systemctl daemon-reload
Then add one line to the [Unit] section of each service you want to hear about. For a backup service, systemctl edit backup.service opens a drop-in file. Add:
[Unit]
OnFailure=ntfy-failed@%n.service
%n is the service's full name. To try it, here is a service that always fails, /etc/systemd/system/demo-backup.service:
[Unit]
Description=Demo backup that fails
OnFailure=ntfy-failed@%n.service
[Service]
Type=oneshot
ExecStart=/bin/sh -c "echo backup target unreachable; exit 1"
systemctl daemon-reload
systemctl start demo-backup.service
The start fails, and a high-priority message titled demo-backup.service failed on ... arrives, with backup target unreachable among its log lines. Remove the demo afterwards with rm /etc/systemd/system/demo-backup.service and systemctl daemon-reload.
9. Alerts from Uptime Kuma and Grafana
Uptime Kuma, from the Uptime Kuma guide, has ntfy built in. Open Settings, Notifications, choose Set Up Notification, and:
- set Notification Type to Ntfy;
- enter
alerts-kumaas ntfy Topic, andhttps://ntfy.example.comas Server URL; - set Authentication Method to Access Token, and paste the token;
- turn on Default enabled and Apply on all existing monitors, choose Test, and then Save.
The test message ntfy Testing arrives on alerts-kuma. When a monitor went down in our test, Kuma sent Closed port test Down [Uptime-Kuma] with the text Connection failed. If Kuma runs on this same server, it needs the containers.conf from step 2 of its guide to reach ntfy.example.com.
Grafana and other tools that send webhooks post JSON. ntfy has a built-in template that turns Grafana's alert payload into a readable title and message: add ?template=grafana to the topic URL. We tested this with the Grafana payload from ntfy's documentation, not from a running Grafana:
curl -H "Authorization: Bearer tk_..." \
--data-binary @grafana-alert.json \
"https://ntfy.example.com/alerts-grafana?template=grafana"
The message arrived titled 🚨 [FIRING:1] Load avg 15m too high, and ✅ [RESOLVED] ... once resolved. ntfy also has templates for github and alertmanager.
A tool that only takes a URL, and cannot send an Authorization header, can put the token in the URL instead. Encode the header's value:
printf 'Bearer tk_...' | base64 -w0 | tr -d '='
and add the result as auth=, as in https://ntfy.example.com/alerts-grafana?template=grafana&auth=QmVhcmVy.... That URL is then as secret as the token.
10. Read them in the browser
Open https://ntfy.example.com/login and sign in as anna with Sign in. Choose Subscribe to topic, enter alerts-server, and Subscribe. Earlier messages from the cache appear at once, and new ones within a second, while the tab is open. The web app keeps your subscriptions in your account, so they appear in every browser where you sign in. Choose Grant now at the top to let the browser show desktop notifications.
The web app loads nothing from other servers. ntfy can also deliver to the browser when the tab is closed, through the browser maker's push service (for Chrome, Google's). That needs extra keys in server.yml, and is left off here.
11. Read them on your phone
You cannot test a phone on a server, so this step follows ntfy's documentation, and we did not try it on a device.
Android. Install ntfy from Google Play or F-Droid. Add your server and the user anna in the app, then subscribe to alerts-server. Google's Firebase is not involved: the Google Play version uses it only for ntfy.sh, never for your own server, and the F-Droid version does not include it at all. For your server, the app keeps its own connection open, which ntfy calls instant delivery, and shows a permanent notification while it does.
iPhone. iOS does not let the app keep its own connection open in the background, so instant notifications need Apple's push service, which ntfy reaches through ntfy.sh. Without that, ntfy's documentation says messages "will still eventually get to your device, but delivery can take hours", and usually no more than 20 to 30 minutes when you are using the phone. To get them at once, add this line to server.yml, and restart ntfy:
upstream-base-url: "https://ntfy.sh"
With it, your server sends ntfy.sh a poll request for every message: the message's ID and the SHA-256 hash of the topic's address, from your server's address, and never the message itself. We checked this on the test server: ntfy contacted ntfy.sh only after the line was added, and the hash in its log was the SHA-256 of https://ntfy.example.com/alerts-server. ntfy.sh passes the request on to Google's Firebase and Apple's push service, which wake the app with the text "New message". The app then fetches the real message from your server, with your account. Leave the line out if nobody uses an iPhone.
12. Limits
ntfy limits each visitor by address, on top of the access rules. The defaults suit a private server:
- 60 requests in a burst, then one more every 5 seconds. Our burst got "429 too many requests" once that was used up.
- About 30 wrong passwords or tokens, then "429 too many auth failures" for a while.
Each IPv6 /64 network counts as one visitor. The limits are settings in server.yml, such as visitor-request-limit-burst, and ntfy's configuration documentation lists them all.
13. Back up
ntfy keeps its accounts, access rules and tokens in user.db, and the cached messages in cache.db, both in its volume. As ntfy:
mkdir -p ~/backup
systemctl --user stop ntfy
podman volume export ntfy-data --output ~/backup/ntfy-data.tar
cp ~/ntfy/server.yml ~/backup/
systemctl --user start ntfy
To restore, as ntfy:
systemctl --user stop ntfy
podman rm -f ntfy
podman volume rm ntfy-data
podman volume create ntfy-data
podman volume import ntfy-data ~/backup/ntfy-data.tar
systemctl --user start ntfy
podman exec ntfy ntfy access then lists the same users and rules, and the old tokens still work. The backup holds the tokens and password hashes: copy ~/backup to another machine, and store it safely.
14. Update
The podman-auto-update.timer from step 3 checks for a new v2 image once a day, and restarts ntfy on it. To see what it would do now:
podman auto-update --dry-run
To update at once, run podman auto-update. ntfy keeps its data in the volume, so an update loses nothing.
Troubleshooting
Everything gets "403 forbidden", even with the token. The topic is outside what the account may use: the token for alerts can only publish to topics that start with alerts-, and cannot read them. podman exec ntfy ntfy access shows the rules.
Every visitor has the server's own address in ntfy's log, and one busy script blocks the others with "429". behind-proxy: true is missing from server.yml, or ntfy was not restarted after it was added.
A file or a long message gets "attachments not allowed". Attachments are off, as in step 2, and a body over 4 KB counts as one. Send less text, such as the last lines of a log instead of the whole log.
Messages are gone after a restart. cache-file is missing, so ntfy kept them in memory only.