ntfy Setup: Self-Hosted Push Notifications for Your Homelab
Run your own push notification server with ntfy. Send alerts from any script or service straight to your phone, with authentication on from the first boot.
This post contains affiliate links. If you buy through them, I earn a small commission at no extra cost to you.
Email alerts are the right default for most homelab notifications, and the homelab email notifications guide covers wiring them up across Proxmox, Docker, and Uptime Kuma. But email has one weakness that matters: I do not check it fast enough. A backup that failed at 2am sits in an inbox until I happen to look. For the small number of things worth interrupting me over, I want a phone buzz within seconds.
That is the job ntfy does on my stack. It is a self-hosted push notification server with a plain HTTP API, which means anything that can run curl can send me a notification. No bot tokens, no third-party chat platform, no account with anyone. This guide sets it up properly, with authentication enabled from the start, HTTPS in front of it, and working push on a phone. You need Docker on the host first, which Docker Compose basics covers, and a reverse proxy, which the Nginx Proxy Manager guide handles.
Why Self-Host a Notification Server
The obvious alternatives are Discord, Telegram, or Slack webhooks. They work, they are free, and they take five minutes. I ran Telegram for a while and eventually pulled it out.
The problem is that all three route the health of your infrastructure through someone else’s platform. Bot tokens get revoked, APIs change, accounts get flagged, and free tiers get squeezed. Then there is the content itself: my alerts contain hostnames, service names, and failure details. That is a light map of my network, sitting in a third party’s message history forever.
ntfy inverts this. The server is a single container on my own hardware. Publishing is an HTTP POST, so every language, every shell script, and every tool that speaks HTTP is already a client. There is a free public instance at ntfy.sh if you want to try the app before committing, but the whole point here is running your own.
| ntfy (self-hosted) | Discord / Telegram | ||
|---|---|---|---|
| Delivery speed | Seconds | 1 to 5 minutes | Seconds |
| Third-party account | No | Mail provider | Yes |
| Sending from a script | curl | SMTP relay setup | Webhook or bot token |
| Alert data leaves your network | No | Yes | Yes |
| Priority levels | Yes, 5 | No | No |
| Ongoing cost | $0 | $0 | $0 |
The one asterisk: instant push on iOS relays through ntfy.sh to reach Apple’s push network, because Apple only accepts pushes from a registered app. That relay is anonymous, needs no account, and carries only a wake-up ping. I cover the setting below.
What You Need
- A host running Docker. Mine runs in an LXC container on Proxmox, but any always-on box works.
- A subdomain pointed at your reverse proxy, for example
ntfy.example.com. - A reverse proxy with a certificate. Nginx Proxy Manager with Let’s Encrypt is what I use.
- The ntfy app on your phone, from the App Store or Google Play.
Step 1: The Compose File
Create a directory for the stack and add docker-compose.yml:
services:
ntfy:
image: binwiederhier/ntfy:latest
container_name: ntfy
command:
- serve
environment:
- TZ=America/New_York
volumes:
- ./config:/etc/ntfy
- ./cache:/var/cache/ntfy
ports:
- "8080:80"
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
Two volumes matter. ./config holds server.yml. ./cache holds both the message cache and the user database, so it is the directory you actually need in your backups. Losing it means recreating every account and token.
The container listens on port 80 internally and I publish it on 8080. Your reverse proxy points at that port. Do not forward 8080 through your router; the proxy is the only thing that should be reachable from outside.
Step 2: Write server.yml
Create config/server.yml:
base-url: "https://ntfy.example.com"
listen-http: ":80"
behind-proxy: true
cache-file: "/var/cache/ntfy/cache.db"
cache-duration: "12h"
auth-file: "/var/cache/ntfy/auth.db"
auth-default-access: "deny-all"
upstream-base-url: "https://ntfy.sh"
Every line here earns its place:
base-url must match the public HTTPS URL exactly. The app builds subscription URLs from it, and a mismatch produces confusing failures later.
behind-proxy: true tells ntfy to trust the forwarded headers from your proxy, so rate limiting works against real client addresses instead of counting everything as one visitor.
auth-default-access: deny-all is the setting people skip, and it is the one that matters most. The default is read-write, which means an instance reachable from the internet lets anyone who guesses a topic name push notifications to your phone or read the ones you receive. Topic names are the only thing standing between a stranger and your notification stream, and they travel in URLs. Set this before the first publish, not after.
cache-duration controls how long messages are retained for devices that were offline. Twelve hours covers a phone that was off overnight without keeping a long history of your infrastructure’s failures on disk.
upstream-base-url is the iOS instant-push relay. Leave it out on Android, where the app can hold its own connection. Without it on iOS, notifications arrive when the app is next opened, which defeats the purpose.
Step 3: Start It and Create Users
docker compose up -d
docker compose logs -f ntfy
With deny-all set, nothing can publish yet. Create two accounts, and give each one only the access it needs:
docker exec -it ntfy ntfy user add publisher
docker exec -it ntfy ntfy user add phone
docker exec -it ntfy ntfy access publisher "*" write-only
docker exec -it ntfy ntfy access phone "*" read-only
The split is deliberate. publisher is the identity sitting in scripts across several machines, so it can send but never read back what it sent. phone reads but cannot publish, so a stolen phone cannot forge alerts. If a script’s credentials leak, the blast radius is noise, not disclosure.
Confirm what you built:
docker exec -it ntfy ntfy access
Step 4: Generate a Token
Scripts should not carry a password. Issue a token instead:
docker exec -it ntfy ntfy token add publisher
You get a tk_ string. Store it in your password manager, then reference it from an environment file rather than pasting it into individual scripts. Tokens are revocable one at a time, so a single compromised host does not force you to rotate credentials everywhere.
Step 5: HTTPS in Front
In Nginx Proxy Manager, add a proxy host:
- Domain:
ntfy.example.com - Scheme:
http, forwarding to your Docker host IP on port8080 - Enable Websockets Support, which the app uses for live connections
- Attach your certificate and enable Force SSL
Verify from a terminal before touching the phone:
curl https://ntfy.example.com/v1/health
You want {"healthy":true}. If you get a certificate error or a gateway error, fix it here. Chasing this from a phone app tells you far less than curl does.
Step 6: Subscribe From Your Phone
Install the app, then add a subscription. This is where nearly everyone loses twenty minutes:
Set the Server field to your own hostname. The app defaults to ntfy.sh, and if you leave it there while entering your self-hosted credentials, you get an error saying the user is not authorized. That message sends you off auditing accounts and ACLs that are perfectly fine. The credentials are valid, just presented to the wrong server.
In the app, go to Settings, add your server under Manage users with the phone account, then subscribe to a topic and pick your server from the dropdown.
Step 7: Send Your First Notification
curl \
-H "Authorization: Bearer tk_yourtokenhere" \
-H "Title: Backup finished" \
-H "Priority: default" \
-H "Tags: floppy_disk" \
-d "Nightly backup completed in 4m12s." \
https://ntfy.example.com/infra
The topic is just the last path segment. Publishing to a topic that does not exist creates it, so there is nothing to provision.
Useful headers:
| Header | Effect |
|---|---|
Title | Bold line above the message body |
Priority | min, low, default, high, urgent. Urgent bypasses most quiet settings |
Tags | Emoji shortcodes shown before the title, useful for scanning |
Click | URL opened when you tap the notification |
Markdown | Set to yes to render formatting in the body |
The Click header is the one that changes how the system feels. Point it at the failing service’s dashboard and the alert becomes one tap from the fix instead of the start of a search.
One real gotcha: HTTP headers are Latin-1. A title containing an em dash or a curly quote, which is easy to introduce when a script builds messages from other text, gets rejected or mangled. Strip non-Latin-1 characters from anything you interpolate into Title. Message bodies are fine, since they travel as the request body rather than a header.
Step 8: Wire It Into What You Already Run
A small wrapper keeps the token in one place:
#!/usr/bin/env bash
# notify.sh: usage: notify.sh <topic> <title> <message> [priority]
set -euo pipefail
: "${NTFY_URL:?NTFY_URL not set}"
: "${NTFY_TOKEN:?NTFY_TOKEN not set}"
if [ "${NTFY_DISABLED:-false}" = "true" ]; then
echo "notify: disabled, skipping" >&2
exit 0
fi
TOPIC="$1"; TITLE="$2"; MESSAGE="$3"; PRIORITY="${4:-default}"
TITLE=$(printf '%s' "$TITLE" | iconv -f UTF-8 -t ASCII//TRANSLIT)
curl -fsS \
-H "Authorization: Bearer ${NTFY_TOKEN}" \
-H "Title: ${TITLE}" \
-H "Priority: ${PRIORITY}" \
-d "${MESSAGE}" \
--max-time 5 \
"${NTFY_URL}/${TOPIC}" > /dev/null
Two details worth copying. The NTFY_DISABLED check is a kill switch: when something starts alert-storming at 3am, you flip one variable rather than editing every job that sends. And --max-time 5 means a notification server that is itself down cannot hang the backup script that was trying to report success.
Call it from the end of any scheduled job:
if /usr/local/bin/backup.sh; then
notify.sh infra "Backup OK" "Nightly backup completed."
else
notify.sh critical "BACKUP FAILED" "Nightly backup exited non-zero." urgent
fi
For Uptime Kuma, add a notification channel of type ntfy with your server URL, topic, and the publisher credentials, then enable it as a default so new monitors pick it up automatically. The advanced alerting guide covers routing rules in more depth, and the homelab automation guide covers the scheduled jobs worth wiring this into.
Design Your Topics Before You Have Twenty
Topics are free to create, which is exactly why they sprawl. Splitting by urgency rather than by source has held up much better for me:
- critical: backup failures, host down. Urgent priority, always audible.
- infra: cron failures, certificate expiry, disk pressure. Default priority.
- ops: routine daily digests and heartbeats. Low priority, silent.
Sorting by source, with a topic per service, produces a phone that buzzes constantly and a person who has stopped reading. Sorting by urgency means the sound itself carries information: a buzz from critical gets attention, and ops can wait for morning.
Two habits keep it that way. Send a weekly heartbeat from your backup job on success, not just on failure, because silence from a broken notifier is indistinguishable from silence from a healthy homelab. And deduplicate before sending: a check running every fifteen minutes against a condition that stays true will send the same alert ninety-six times a day. Track what you have already sent, add a cooldown per alert type, and only re-notify when the state actually changes. I learned that one the loud way, with a misconfigured sweep that pushed dozens of notifications in a few minutes before I found the kill switch.
Cost and Footprint
The software is free and the container idles under 100MB of RAM, well below what a monitoring stack like Prometheus and Grafana needs. It runs comfortably on hardware you already have. If you are still choosing that hardware, a 16GB RAM mini PC hosts ntfy alongside a full Docker stack without noticing it.
The real cost is attention, and it is spent the first week. Point too much at push and you will train yourself to swipe alerts away unread, which is worse than no alerts at all, because you will believe you are covered. Start with backups and host availability on critical, leave everything else on email, and promote a category to push only after it wakes you for something you were genuinely glad to know about. Once that line is drawn, securing the rest of the stack follows the same principle: the fewer things demanding your attention, the more likely you are to notice the one that matters.