Your backup script finishes at 3 AM. Did it work? Did it silently eat 200GB of disk and fail halfway through? You have no idea until you SSH in the next morning and find out the hard way.
Email alerts sound like the obvious fix until you realize you’ve been filtering them to a folder you check twice a month. Telegram bots require a bot token, a chat ID, and a prayer that the API doesn’t break. Paid services like PagerDuty or Pushover cost money and route your internal event data through someone else’s cloud.
ntfy.sh solves this cleanly. It’s an open-source, self-hosted pub/sub notification service — you POST to a topic URL, it delivers the message to your phone. No accounts, no SDK, no nonsense. A single curl call is all it takes from anywhere: a shell script, a Python job, a GitHub Action, or a systemd unit.
The official GitHub repo is here: github.com/binwiederhier/ntfy
You can use the public ntfy.sh server for testing, but running your own instance means your server events never leave your network (unless you want them to), you control access, and you’re not subject to rate limits or the author’s server going down.
Let’s build it.
What ntfy Actually Does
The model is dead simple: topics are just URL path segments. You publish to a topic with a POST, and anyone subscribed to that topic (via the app, a browser, or another curl) gets the notification.
POST https://ntfy.yourdomain.com/backup-completed
Body: "Backup finished: 14.2 GB, took 4m 32s"
That’s it. No pre-registration of topics, no configuration needed on the publisher side. The topic is created implicitly on first use.
The mobile apps (Android via F-Droid or Google Play, iOS on the App Store) maintain a persistent connection using WebSockets or SSE. Message delivery is near-instant.
Deploying with Docker Compose
This is the sane way to run it. No fighting with systemd unit files, no wondering what version apt installed.
Create a working directory:
mkdir -p ~/ntfy/{data,config}
cd ~/ntfy
Now the compose file:
# docker-compose.yml
version: "3.8"
services:
ntfy:
image: binwiederhier/ntfy:latest
container_name: ntfy
restart: unless-stopped
command: serve
environment:
- TZ=Europe/Berlin # Set to your timezone
volumes:
- ./config:/etc/ntfy # ntfy server.yml lives here
- ./data:/var/lib/ntfy # persistent message cache and attachments
ports:
- "127.0.0.1:8080:80" # bind only to loopback — nginx handles TLS
healthcheck:
test: ["CMD-SHELL", "wget -q --tries=1 https://cd-linux.club:80/v1/health -O - | grep -c '\"healthy\":true' || exit 1"]
interval: 60s
timeout: 10s
retries: 3
Notice the port binding: 127.0.0.1:8080:80. Never expose ntfy directly on a public port without TLS. Put nginx or Caddy in front of it.
Server Configuration
Create config/server.yml. This is where the interesting stuff happens:
# config/server.yml
# Base URL — must match what clients connect to
base-url: "https://ntfy.yourdomain.com"
# Listen address inside the container
listen-http: ":80"
# Cache: how long to store messages for offline clients
cache-file: "/var/lib/ntfy/cache.db"
cache-duration: "24h"
# Attachments (files sent with notifications)
attachment-cache-dir: "/var/lib/ntfy/attachments"
attachment-total-size: "1G"
attachment-expiry-duration: "72h"
# Authentication — STRONGLY recommended for a public-facing instance
auth-file: "/var/lib/ntfy/user.db"
auth-default-access: "deny-all" # no anonymous reads or writes
# Behind a reverse proxy — tell ntfy to trust X-Forwarded-For
behind-proxy: true
# Rate limiting — sensible defaults, tune to taste
visitor-request-limit-burst: 60
visitor-request-limit-replenish: "5s"
visitor-email-limit-burst: 16
visitor-email-limit-replenish: "1h"
# Logging
log-level: "info"
log-format: "json" # easier to parse with loki or grep
Start it:
docker compose up -d
docker compose logs -f ntfy
nginx Reverse Proxy with TLS
If you’re already running nginx with Certbot or acme.sh on the host, this config drops in cleanly:
# /etc/nginx/sites-available/ntfy
server {
listen 80;
server_name ntfy.yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name ntfy.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/ntfy.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ntfy.yourdomain.com/privkey.pem;
# ntfy needs large body size for file attachments
client_max_body_size 20m;
# Required for WebSocket (the mobile apps use it for real-time delivery)
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# WebSocket headers
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Prevent nginx from buffering SSE streams
proxy_buffering off;
proxy_read_timeout 3600s; # long timeout for persistent connections
}
}
Enable and reload:
ln -s /etc/nginx/sites-available/ntfy /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
Setting Up Users and Access Control
With auth-default-access: deny-all, nothing works until you create users. The ntfy CLI is baked into the Docker image:
# Create an admin user (can read/write all topics)
docker exec -it ntfy ntfy user add --role=admin nikita
# Create a dedicated publisher (write-only to all topics)
docker exec -it ntfy ntfy user add scripts-bot
# Grant that user write access to specific topics
docker exec -it ntfy ntfy access scripts-bot backup-alerts rw
docker exec -it ntfy ntfy access scripts-bot cron-alerts rw
# Verify
docker exec -it ntfy ntfy user list
docker exec -it ntfy ntfy access list
The scripts-bot user is what you embed in your automation. If it leaks, it only has write access to specific topics — your admin account and the ability to subscribe are separate.
Sending Notifications from Bash
This is where the payoff happens. The ntfy API is HTTP, so curl is all you need.
Basic notification:
#!/usr/bin/env bash
# Credentials — load from environment or a secrets file, never hardcode
NTFY_URL="https://ntfy.yourdomain.com"
NTFY_TOPIC="backup-alerts"
NTFY_TOKEN="tk_yourtokenhere" # generate with: ntfy token add scripts-bot
curl -s \
-H "Authorization: Bearer ${NTFY_TOKEN}" \
-H "Title: Backup Complete" \
-H "Priority: default" \
-H "Tags: white_check_mark" \
-d "Server: prod-01 | Duration: 4m32s | Size: 14.2 GB" \
"${NTFY_URL}/${NTFY_TOPIC}"
With priority and emoji tags:
ntfy supports emoji shortcodes in the Tags header — they show up as emoji on the notification. Priorities range from min to max. Use urgent or high for actual problems; save max for things that should wake you up.
# Alert on backup failure
send_alert() {
local title="$1"
local message="$2"
local priority="${3:-high}"
local tags="${4:-warning}"
curl -s \
-H "Authorization: Bearer ${NTFY_TOKEN}" \
-H "Title: ${title}" \
-H "Priority: ${priority}" \
-H "Tags: ${tags}" \
-d "${message}" \
"${NTFY_URL}/${NTFY_TOPIC}" > /dev/null
}
# Usage:
send_alert "Backup Failed" "rsync exited with code 23 on prod-01" "urgent" "rotating_light"
Wrap a command and notify on completion:
#!/usr/bin/env bash
# notify-wrap.sh — run a command, send result to ntfy
# Usage: ./notify-wrap.sh "Restic backup" restic backup --repo /mnt/backup /home
LABEL="$1"
shift
START=$(date +%s)
OUTPUT=$("$@" 2>&1)
EXIT_CODE=$?
DURATION=$(( $(date +%s) - START ))
if [ $EXIT_CODE -eq 0 ]; then
TITLE="${LABEL}: Success"
PRIORITY="default"
TAGS="white_check_mark"
else
TITLE="${LABEL}: FAILED (exit ${EXIT_CODE})"
PRIORITY="high"
TAGS="rotating_light"
fi
curl -s \
-H "Authorization: Bearer ${NTFY_TOKEN}" \
-H "Title: ${TITLE}" \
-H "Priority: ${PRIORITY}" \
-H "Tags: ${TAGS}" \
-d "Duration: ${DURATION}s\n\n${OUTPUT:0:1000}" \
"${NTFY_URL}/cron-alerts" > /dev/null
Wrap any command with it: ./notify-wrap.sh "DB Dump" pg_dump mydb > dump.sql
Wiring Into Cron
The cleanest pattern is using MAILTO="" to suppress the default email behavior and routing everything through ntfy instead:
# /etc/cron.d/backup-job
MAILTO=""
NTFY_TOKEN=tk_yourtokenhere
# Run backup at 2 AM, notify on any exit code
0 2 * * * root /usr/local/bin/notify-wrap.sh "Nightly Backup" /opt/scripts/backup.sh
If you want only failure alerts (less noise), check the exit code inside the script and only call ntfy when things go wrong.
Systemd Service Notifications
For long-running services or one-shot units, you can hook ntfy into systemd’s OnFailure mechanism:
# /etc/systemd/system/[email protected]
[Unit]
Description=ntfy notification for %i
[Service]
Type=oneshot
ExecStart=/usr/local/bin/ntfy-systemd-notify.sh "%i"
#!/usr/bin/env bash
# /usr/local/bin/ntfy-systemd-notify.sh
UNIT="$1"
STATUS=$(systemctl is-active "$UNIT" 2>&1)
curl -s \
-H "Authorization: Bearer ${NTFY_TOKEN}" \
-H "Title: Service failure: ${UNIT}" \
-H "Priority: high" \
-H "Tags: fire" \
-d "Unit ${UNIT} is now: ${STATUS}" \
"https://ntfy.yourdomain.com/service-alerts" > /dev/null
Then in any service unit you want to monitor:
[Unit]
OnFailure=ntfy-notify@%n.service
Now if nginx.service crashes, your phone rings.
Sending from Python
Sometimes bash isn’t the tool. Python’s requests library makes this trivial:
import requests
import os
def notify(title: str, message: str, priority: str = "default", tags: str = "bell"):
token = os.environ["NTFY_TOKEN"]
url = f"https://ntfy.yourdomain.com/python-jobs"
resp = requests.post(
url,
data=message.encode("utf-8"),
headers={
"Authorization": f"Bearer {token}",
"Title": title,
"Priority": priority,
"Tags": tags,
},
timeout=10,
)
resp.raise_for_status()
# At the end of a long ML training run:
notify(
title="Training Complete",
message=f"Epoch 50/50 | Val loss: 0.0234 | Acc: 94.7%",
tags="tada"
)
Gotchas
Gotcha: WebSocket timeouts behind nginx
If mobile clients keep disconnecting, the first suspect is proxy_read_timeout. The default nginx value is 60 seconds — ntfy’s persistent connections outlive that. Set it to at least 3600s as shown in the nginx config above.
Gotcha: Topics are not private by default
On a fresh install without auth-default-access: deny-all, anyone who guesses your topic name can subscribe to it. If you’re posting server events that reveal internal hostnames, IP addresses, or script output, you absolutely want authentication enabled. Don’t skip this step.
Gotcha: Token vs password authentication
You can authenticate with user:password basic auth, but tokens (ntfy token add) are better for automation — they’re revocable without changing the password, and you can issue one per script/host, making it easy to audit and rotate.
Gotcha: Message size limits
The default message body limit is 4096 bytes. Script output can easily exceed this. Truncate with ${OUTPUT:0:1000} in bash, or send long output as an attachment rather than the body.
Gotcha: latest tag drift
Pinning to binwiederhier/ntfy:latest means you’ll get new versions on docker compose pull — which is convenient but occasionally breaks things when the config format changes. Check the GitHub releases page and pin to a specific version tag in production. Use Watchtower or a weekly manual pull cycle with a change review.
Gotcha: iOS delivery requires APNS
On iOS, the ntfy app doesn’t maintain a persistent background connection — Apple’s push infrastructure (APNS) is the delivery path. This means for iOS subscribers, you either use the public ntfy.sh relay (the self-hosted server forwards to ntfy.sh APNS gateway) or configure your own APNS credentials. The upstream docs cover this. Android with UnifiedPush or WebSocket is simpler and doesn’t have this constraint.
Production-Ready Additions
Log shipping to Loki: With log-format: json in server.yml, ntfy logs are trivially parseable by Promtail. Add a scrape job pointing at the container’s stdout and you get searchable notification history in Grafana.
Uptime monitoring: Subscribe to ntfy’s own health endpoint. A simple cron job that hits /v1/health and sends an alert to a different notification channel (e.g., a free Pushover account as a fallback) gives you a dead-man’s switch.
Backup the database: ntfy stores messages and user data in SQLite files under /var/lib/ntfy. Include the data/ directory in your regular backup job. The files are small and rsync-friendly.
Use a .env file for tokens:
# .env (chmod 600, never commit to git)
NTFY_TOKEN=tk_abc123xyz
NTFY_URL=https://ntfy.yourdomain.com
# Load in scripts
set -a; source /etc/ntfy-env; set +a
Or use systemd’s EnvironmentFile= directive in service units. Either way, keep credentials out of scripts and cron entries.
The Honest Assessment
ntfy is genuinely one of the most useful self-hosted tools for anyone running a homelab or managing servers. The protocol is so simple that you’ll find yourself adding notifications to things you’d never bothered with before — because the cost of doing so is one curl line.
The main trade-off versus something like Gotify is the mobile app ecosystem. ntfy has native apps for both platforms, solid WebSocket support, and the UnifiedPush standard built in — Gotify’s iOS situation has historically been worse. If you’re an Android-first household, both work well. If iOS matters, ntfy’s APNS relay is the path of least resistance.
The authentication model could be more granular (topic-level ACLs via the CLI feel clunky for large setups), but for most homelab use cases — a handful of topics, a few users — it’s more than enough.
Self-host it. Wire it into your backup scripts tonight. You’ll sleep better knowing something is watching the jobs while you aren’t.