2026-09-03

ntfy: Push Notifications From Your Own Scripts

A script that fails silently only tells you it broke when someone finally notices. One curl call fixes that, if you get the one secret right.

Cron jobs, deploys and backup scripts all fail silently by default. The job exits non-zero, the error sits in a log file nobody's tailing, and the way you find out is days later, when the backup you needed isn't there, or a client asks why the site's been down since Tuesday. ntfy fixes the specific failure of nobody watching the terminal: one curl call from inside a script and the outcome shows up as a phone notification, no app install on the sending side, no account, no SDK. I run it as one of the notification paths off my own automation. This is what it actually takes to wire in, and the one thing about it that's easy to get wrong.

What ntfy actually is

ntfy (say it 'notify') is a pub/sub notification service built on plain HTTP. There's no message broker to configure and no client library to install: publishing is a POST request, subscribing is a GET request ntfy holds open, or a phone app doing the same thing under the hood. The unit everything hangs off is the topic, just a string, created the moment something first publishes or subscribes to it. There's no signup and no topic registration on the public instance at ntfy.sh.

curl -d "Backup finished" ntfy.sh/your-topic-name

That's the whole interface for the simple case. Anyone subscribed to your-topic-name, phone app, browser tab, another curl call, gets that message pushed within a second or two. Scripts, cron jobs and CI pipelines all just need curl or any HTTP client, so it drops into anything that can already shell out.

Priorities and tags, so a failure doesn't look like a success

A notification that fires on every run, success or failure, trains you to stop reading it within a week. That is the same outcome as not having notifications at all, just with extra steps. ntfy gives you two cheap levers to make different outcomes actually look different on the lock screen: priority and tags.

# a failed deploy: urgent priority, warning icon, hard to ignore
curl -H "Priority: urgent" -H "Tags: rotating_light" \
     -H "Title: deploy failed" \
     -d "build exited 1 on prod" \
     ntfy.sh/your-topic-name

# a routine backup: low priority, easy to skim past
curl -H "Priority: low" -H "Tags: white_check_mark" \
     -H "Title: nightly backup" \
     -d "pg_dump completed" \
     ntfy.sh/your-topic-name

Urgent priority on most phones bypasses Do Not Disturb and keeps nudging until you open it, which is exactly the behaviour you want for 'the thing that makes money is down' and exactly the behaviour you don't want for 'the nightly backup ran, same as always'. Reserve urgent for genuine failures. Fire it for routine success too and you've built a phone that yells at you constantly, at which point the one urgent notification that actually matters looks identical to the noise.

The security point that matters most

On the public instance, the topic name is the only thing standing between your notifications and anyone else's. There's no account and no per-message auth by default: knowing, or guessing, a topic name is enough to both read every message ever sent to it and publish new ones as though they were yours. A topic called backups or alerts or deploy-status will eventually get found, whether by someone scanning obvious names or by accident. If the topic name doubles as a description of what's inside it, you've published that description right alongside it.

The fix is to treat the topic name as a credential, because on the public instance it is one. Generate something unguessable rather than something memorable:

openssl rand -hex 16
# use the output as the topic, e.g. ntfy.sh/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4

Keep it out of anything public: don't commit it to a repo, don't paste it into a shared runbook, pull it from an environment variable or a secrets file the same way you would an API key. If a topic does leak, the only remediation is picking a new one; ntfy has no way to revoke access to an old topic short of abandoning it.

Self-hosting versus ntfy.sh

The public ntfy.sh instance is free, requires nothing to stand up, and is what most of the examples above assume. Self-hosting swaps that convenience for control: you can turn on real authentication, usernames and passwords or per-topic access tokens, with a deny-by-default policy so a topic only works for whoever you've explicitly granted access to, rather than whoever finds the string. I run a self-hosted instance this way for my own approval and alerting traffic, auth on, deny-all default, rather than trusting an unguessable topic name alone.

Self-hosting doesn't fully remove ntfy.sh from the picture if you want notifications on iOS. Apple's push service requires going through Apple's own certificate infrastructure, and the ntfy iOS app is set up to relay through ntfy.sh for that leg unless you build and sign your own iOS client with your own Apple push credentials. A self-hosted server still talks to ntfy.sh's relay to reach an iPhone. That's a reasonable tradeoff, message contents can be minimised in what actually crosses that relay, but it's worth knowing before you assume self-hosting means nothing ever touches someone else's server again.

Wiring it into a script's exit path

The version worth actually deploying doesn't fire a notification on every run. It notifies on failure, loudly, and either stays quiet on success or notifies quietly. A trap on the script's error path is the simplest way to do that in bash:

#!/usr/bin/env bash
set -euo pipefail

NTFY_URL="https://ntfy.sh/your-topic-name"

notify_failure() {
  curl -s -H "Priority: urgent" -H "Tags: rotating_light" \
       -H "Title: nightly-backup failed" \
       -d "exit on $(hostname) at $(date -Iseconds)" \
       "$NTFY_URL" >/dev/null
}
trap notify_failure ERR

pg_dump mydb > "/backups/mydb-$(date +%F).sql"
aws s3 cp "/backups/mydb-$(date +%F).sql" s3://my-backups/

curl -s -H "Priority: low" -H "Tags: white_check_mark" \
     -H "Title: nightly-backup ok" \
     -d "backup completed" "$NTFY_URL" >/dev/null

set -euo pipefail makes the script exit on the first failing command, and the ERR trap catches that exit and fires the notification before the script actually dies. Everything after the trap line, the actual backup and upload, only produces the quiet success ping if both commands made it through.

Avoiding notification fatigue on purpose

A few habits keep this useful past the first week:

None of this is complicated once it's wired in, which is exactly why it's worth doing before the next script fails quietly. The topic name is the one decision that matters: treat it like a password on the public instance, or move to a self-hosted server with real auth if the traffic is sensitive enough to want more than an unguessable string standing between it and anyone who finds it.

Topics

Still stuck? Send it to Emsden Studio and get a straight answer on what it is and what fixing it costs.

More guides

All guides