← All Guides
intermediate

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.

Budget Homelab ·
dockermonitoringalertingntfyself-hosting

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)EmailDiscord / Telegram
Delivery speedSeconds1 to 5 minutesSeconds
Third-party accountNoMail providerYes
Sending from a scriptcurlSMTP relay setupWebhook or bot token
Alert data leaves your networkNoYesYes
Priority levelsYes, 5NoNo
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

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:

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:

HeaderEffect
TitleBold line above the message body
Prioritymin, low, default, high, urgent. Urgent bypasses most quiet settings
TagsEmoji shortcodes shown before the title, useful for scanning
ClickURL opened when you tap the notification
MarkdownSet 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:

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.