SSD Nodes Learn 🎉 VPS from $5.50/mo
How to do am Matt ConnorBy Matt Connor

How to self-host ntfy for server alerts

Run ntfy for your own server push alerts with Docker Compose, TLS, users, and ACLs. Send cron and systemd OnFailure notifications without any client library.

Wetin self-hosted ntfy server dey do

Self-hosted ntfy server dey turn HTTP POST into push notification for your phone. You publish with curl, and message go reach Android app, iOS app, browser tab, or anything wey fit keep HTTP connection open. You no need install client library or run message broker.

ntfy dey address messages with topic. Topic na name for URL path, like https://ntfy.example.com/alerts, and e dey exist immediately person publish to am. For default install, anybody wey know that name fit read the topic and write to am. Na why the project documentation compare topic name to password. This model good for public ntfy.sh service. E no good for server wey dey carry your backup failures, so this guide go turn authentication on before anybody send the first message.

Wetín you need before you start

You need a VPS wey dey run Ubuntu 24.04 or Debian 13 with Docker Engine and the Compose plugin, one domain name, and very small RAM. Create DNS (domain name system) A record wey point ntfy.example.com to the server public IP address, then confirm say e dey resolve before you do anything else.

dig +short ntfy.example.com
sudo ufw allow 80,443/tcp
sudo ufw status

dig suppose print your server IP. Certificate issuance go fail if e print nothing, because the certificate authority dey check the name from outside. Port 80 stay open because ACME (automatic certificate management environment), the protocol wey dey power Let's Encrypt, dey use am for the HTTP challenge. The ntfy container itself no get any public port.

Write the ntfy config file

Docker image no get config file inside, so you go create one. Every command later for this guide go read from am. First find the user ID and group ID wey the container go run as.

id -u
id -g
sudo install -d -o "$(id -u)" -g "$(id -g)" /etc/ntfy /var/cache/ntfy /var/lib/ntfy
sudo nano /etc/ntfy/server.yml
base-url: "https://ntfy.example.com"
listen-http: ":2586"
behind-proxy: true
cache-file: "/var/cache/ntfy/cache.db"
cache-duration: "12h"
auth-file: "/var/lib/ntfy/user.db"
auth-default-access: "deny-all"
enable-login: true
enable-signup: false

Four of those lines carry the main load. base-url must be the exact public HTTPS address, because ntfy dey use am build attachment links and make requests from the web app itself. If the value no correct, web app fit load but every action go fail. listen-http: ":2586" bind to all interfaces inside the container. This one fit look careless, but na the correct setup: container get its own network namespace. If you bind to 127.0.0.1 there, host no go reach the port, and Docker published port no go connect. auth-default-access: "deny-all" define the whole security posture, because e dey reject read and write access for anybody wey no get explicit grant. behind-proxy: true tell ntfy to take the client address from the X-Forwarded-For header, so rate limits go count real visitors instead of counting the reverse proxy as one client wey too busy.

enable-login: true allow the web app and phone apps make dem sign in with password. enable-signup remain false, because self-service account creation for private server na open door with extra steps.

sudo chown "$(id -u):$(id -g)" /etc/ntfy/server.yml
sudo chmod 600 /etc/ntfy/server.yml

Run ntfy with Docker Compose

Put this for /opt/ntfy/compose.yaml, replace 1000:1000 with the two numbers id -u and id -g wey print above.

services:
  ntfy:
    image: binwiederhier/ntfy:v2.27.0
    container_name: ntfy
    command: serve
    user: "1000:1000"
    environment:
      - TZ=UTC
    volumes:
      - /etc/ntfy:/etc/ntfy
      - /var/cache/ntfy:/var/cache/ntfy
      - /var/lib/ntfy:/var/lib/ntfy
    ports:
      - "127.0.0.1:2586:2586"
    restart: unless-stopped
cd /opt/ntfy
sudo docker compose up -d
sudo docker compose logs ntfy
curl -s http://127.0.0.1:2586/v1/health

Healthy server go answer {"healthy":true}. Two things for that compose file dey intentional. Dem pin the image to v2.27.0, wey be the current release as of August 2026, instead of latest. If you use latest, the next docker compose pull go change your server version, and na changelog you go use find out later. Dem publish the port as 127.0.0.1:2586:2586, so container fit reachable only from host loopback address. If you write 2586:2586 instead, Docker go put its own firewall rules before your own rules. This means the port go answer from internet even though ufw status talk say the port dey closed.

If curl print Connection refused, read the container log. Permission error for /var/lib/ntfy/user.db mean say the user: line no match the owner of those directories. So the process no fit create its own database and e go exit. The Docker Compose basics guide for a VPS explain volume ownership and restart policies with more detail.

Put TLS in front with Caddy

Caddy dey request and renew the certificate by itself. Na the shortest way to get working TLS (transport layer security).

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy

Replace the contents of /etc/caddy/Caddyfile with three lines.

ntfy.example.com {
    reverse_proxy 127.0.0.1:2586
}
sudo systemctl reload caddy
curl -s https://ntfy.example.com/v1/health

The same {"healthy":true} for HTTPS mean say the whole path dey work. A 502 from Caddy mean say ntfy no dey listen: check am with sudo ss -lntp | grep 2586. Certificate error usually mean say DNS record no correct or port 80 dey blocked, and sudo journalctl -u caddy -n 50 dey show which one.

If nginx already dey run for your server, copy the proxy settings wey ntfy document: proxy_http_version 1.1, proxy_buffering off, proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for, and set read and send timeouts to at least three minutes. Subscriber dey keep one HTTP connection open for as long as e dey listen. By default, nginx dey close idle upstream connection after 60 seconds. Because of this, subscribers dey reconnect continuously, and dem fit miss messages wey people send during the gap.

Create users and lock topics down

Authentication don turn on, and nobody get access to anything yet. Na so e suppose be. Create one admin account for yourself and one machine account for scripts. These commands dey read /etc/ntfy/server.yml from inside the container, na why the config file be volume mount.

sudo docker compose exec ntfy ntfy user add --role=admin admin
sudo docker compose exec ntfy ntfy user add robot
sudo docker compose exec ntfy ntfy user list

Each command go ask for password. Admin no dey follow the access list, and e fit read and write every topic. So keep that account for yourself and the phone app. robot na ordinary user wey no get any access until you grant some.

sudo docker compose exec ntfy ntfy access robot alerts write
sudo docker compose exec ntfy ntfy access robot "alerts_*" write
sudo docker compose exec ntfy ntfy access

ACL (access control list) entry na user, topic, and permission. Topic fit be exact name or pattern where * match anything. So alerts_* fit cover alerts_backup and alerts_db without one command for every host. Permission write mean publish only. This one mean say if person steal token from cron job, e no fit subscribe and read wetin e send back. Special username everyone set wetin unauthenticated visitor fit do. Use am only when you deliberately wan make something public, like ntfy access everyone status read.

Scripts suppose carry token, no be your password.

sudo docker compose exec ntfy ntfy token add robot

The command go print token wey start with tk_. Token inherit exactly the access of the user wey e belong to. So this one fit publish to the alerts topics and do nothing else. ntfy token list show wetin dey exist, and ntfy token remove revoke one without touching the user's password.

Send your first message and prove say the lock dey work

Start by checking say the door don shut.

curl -s -o /dev/null -w '%{http_code}\n' -d "hello" https://ntfy.example.com/alerts

That go print 403, and 403 na the correct answer: auth-default-access: "deny-all" no dey allow anonymous publish. Now send one real message.

curl -H "Authorization: Bearer tk_REPLACE_WITH_YOUR_TOKEN" \
  -H "Title: Nightly backup finished" \
  -H "Priority: default" \
  -H "Tags: white_check_mark" \
  -d "42 GB copied in 11 minutes" \
  https://ntfy.example.com/alerts

The server go reply with the stored message as JSON. Na so you know say e accept am instead make e swallow am. Title na the bold first line. Priority dey run from 1 to 5, or by name from min to urgent. E decide whether the phone go make sound. Tags go turn to emoji for the notification when the name match known emoji short code, and dem go remain plain text when e no match.

To watch one topic from terminal, stream am:

curl -s -u admin https://ntfy.example.com/alerts/raw

curl go prompt you for the password. Each message go arrive as one line, and the blank lines wey show sometimes na keepalives. Open https://ntfy.example.com for browser and sign in with the same account to get the web app version of the same stream.

Set rate limits make one script no fit flood the server

By default, each visitor get bucket of 60 requests, and e dey refill one request every 5 seconds. This one generous for private server, and script wey dey stuck for retry loop go use everything. Add limits to server.yml.

visitor-request-limit-burst: 30
visitor-request-limit-replenish: "10s"
visitor-message-daily-limit: 500
sudo docker compose restart ntfy

Visitor wey pass the limit go get HTTP 429 instead of message delivery. The system dey count the limit per visitor address. Na why behind-proxy: true important well-well: without am, ntfy go see only Caddy address. Every client go count as the same visitor, and one noisy script fit finish the bucket wey your phone and other servers dey share.

Alert from cron job wey fail

Keep the token comot from command line. ps aux dey show the full command line of every process wey dey run to every user for the box, so any local account fit read token wey you pass with -H for as long as curl dey run. curl config file go avoid this.

sudo install -d -m 700 /etc/ntfy-alert
printf 'header = "Authorization: Bearer tk_REPLACE_WITH_YOUR_TOKEN"\n' | sudo tee /etc/ntfy-alert/curlrc
sudo chmod 600 /etc/ntfy-alert/curlrc

Now wrap the job. Save am as /usr/local/bin/backup-with-alert.sh and chmod 750 am.

#!/bin/bash
out=$(/usr/local/bin/backup.sh 2>&1)
code=$?
if [ "$code" -ne 0 ]; then
  printf '%s' "$out" | tail -c 1000 | curl -K /etc/ntfy-alert/curlrc \
    -H "Title: backup.sh failed with exit $code" \
    -H "Priority: high" \
    -H "Tags: warning" \
    --data-binary @- \
    https://ntfy.example.com/alerts
fi
exit "$code"
17 3 * * * /usr/local/bin/backup-with-alert.sh >> /var/log/backup-alert.log 2>&1

$? dey captured for the line immediately after the command, because the next command wey run go overwrite am. The output dey pass through tail -c 1000 because ntfy get maximum message size, and notification no be log viewer. The closing exit "$code" dey preserve the original status, so anything else wey dey watch this job still go see say e fail. Test everything by pointing the script to /bin/false for one run.

Failure branch wey never execute worse pass no alerting, because e go look like silence mean success. Cron dey give your job almost empty environment and much shorter PATH than your login shell, so script wey dey work when you run am by hand fit die before e ever reach the curl line. The guide on why cron job no dey run cover those environment traps. Use absolute paths everywhere, and read the log file after the first scheduled run instead of assuming.

Alert when systemd unit fail

Cron dey handle scheduled work. Long-running services need OnFailure=, wey systemd dey run anytime unit enter failed state. Create one template unit and reuse am for every service for the server. Save am as /etc/systemd/system/ntfy-unit-failed@.service.

[Unit]
Description=Send an ntfy alert because %i failed

[Service]
Type=oneshot
ExecStart=/usr/local/bin/ntfy-unit-failed %i

Then /usr/local/bin/ntfy-unit-failed, mode 750:

#!/bin/bash
unit="$1"
journalctl -u "$unit" -n 15 --no-pager -o cat | tail -c 1000 | curl -K /etc/ntfy-alert/curlrc \
  -H "Title: $unit failed on $(hostname -s)" \
  -H "Priority: urgent" \
  -H "Tags: rotating_light" \
  --data-binary @- \
  https://ntfy.example.com/alerts

Attach am to service with drop-in, so package upgrade no go overwrite your edit.

sudo systemctl edit myapp.service
[Unit]
OnFailure=ntfy-unit-failed@%n.service

%n dey expand to full unit name, so the instance go become ntfy-unit-failed@myapp.service, while %i inside the template go hand myapp.service to the script as the first argument. Na this one make one template fit serve every unit. Test am with unit wey go fail on purpose, saved as /etc/systemd/system/ntfy-selftest.service.

[Unit]
Description=Deliberately failing unit
OnFailure=ntfy-unit-failed@%n.service

[Service]
Type=oneshot
ExecStart=/bin/false
sudo systemctl daemon-reload
sudo systemctl start ntfy-selftest.service

The start command go exit with non-zero status and print Job for ntfy-selftest.service failed because the control process exited with error code, and the phone suppose buzz about one second later. Delete the test unit afterwards.

One trap need attention. OnFailure= dey run only when unit reach failed state, and service wey get Restart=always fit never reach am, because systemd go keep restarting am instead. The unit go fail only after e pass StartLimitBurst restarts inside StartLimitIntervalSec. Set those two values for any service wey you want receive alert for, otherwise crash loop fit continue quietly for days. Timers dey serve as cleaner replacement for the cron pattern above, because timer service unit dey get OnFailure= free of charge, and the guide to systemd services and timers on a VPS dey explain how to convert one.

Wire uptime monitor connect to the same topic

Uptime Kuma, self-hosted status monitor, get ntfy notification type. Open Settings, then Notifications, then Setup Notification, choose Ntfy, set the server URL to https://ntfy.example.com and the topic to alerts, choose priority, then paste the robot access token. Send test notification before you save am, because wrong topic name fit fail silently when write grant no cover am.

The honest limit of this setup be say: monitor wey dey run for the same VPS no fit tell you say VPS don go down, and ntfy no fit deliver news say ntfy don go down. Run the monitor for another machine, and give am second notification channel, like email, for the monitor wey dey watch ntfy itself. Uptime Kuma Push monitor type cover the other blind spot: your cron job call push URL after successful run, and Kuma alert when those calls stop to arrive. Failure branch only fire when the job run, so e no talk anything about job wey never start.

Self-hosted ntfy Android and iPhone work dey?

For Android, yes, no condition. Install the app from Google Play or F-Droid, open Settings, set default server to https://ntfy.example.com, add your account for the user management screen, then subscribe to alerts. Instant delivery dey keep a foreground service running, so messages go arrive even when phone dey doze mode. The permanent notification wey come with am na Android requirement for foreground services, no be bug. The F-Droid build no get any Firebase code at all, so every subscription dey use instant delivery. ntfy fit also act as UnifiedPush distributor, wey be open replacement for Google's push service, so other apps wey support UnifiedPush fit deliver through your server too.

For iOS, e dey work with one dependency wey you no fit remove. Apple fit wake backgrounded app only through APNs (Apple push notification service). Na only the party wey hold the app's signing credentials fit send to am. So your server no get way to reach the app directly. ntfy solve this with a relay: your server sends a poll_request wey hold the message ID to ntfy.sh. ntfy.sh forward am through Firebase and APNs to wake the app. Then the app fetch the message body from your server.

upstream-base-url: "https://ntfy.sh"

Make you understand the cost clearly. The message content remain for your box, but the fact say message arrive, plus the ID, pass through infrastructure wey you no run. Without this setting, notifications on iPhone from self-hosted server go arrive late or no arrive at all, because nothing dey wake the app. The only way to remove the relay na to build and ship the iOS app yourself with your own Apple developer account and your own APNs keys. This one mean annual fee and rebuild for every update. If the relay no acceptable for your use, keep alerting for Android or desktop web app.

Backups, upgrades and pinning the image

Paths two no fit regenerate: /etc/ntfy/server.yml and /var/lib/ntfy/user.db. The second one hold every user, password hash, ACL entry and token, so handle am like private key.

sudo tar czf ntfy-backup.tgz -C / etc/ntfy var/lib/ntfy
sudo chmod 600 ntfy-backup.tgz

Copy that file comot from the server. cache.db hold only recent messages, na 12 hours of messages with the cache-duration above, so if e lost, you no lose anything wey worth protecting. To upgrade, edit the tag for compose file, then pull.

sudo docker compose pull
sudo docker compose up -d
curl -s https://ntfy.example.com/v1/health

Read the release notes first. The SQLite databases migrate when e start, so rollback go older tag after schema change no safe. Keep the backup wey you just make until the new version don run for one day.

Gotify na di smaller option: e get one binary, web UI and Android app. E no support topic wildcards, and no official iOS client dey. Dis one fit private box wey Android na di only target.

Apprise na Python library and command line tool, no be server. E fit send one message go more than hundred services, including ntfy. Dis one fit script wey need reach different places at di same time.

ntfy na di one wey give you server, HTTP API and apps for both mobile platforms. Na why people normally use am for alerting from rented server.

FAQ

Why publishing to my ntfy server dey return 403?

With auth-default-access: "deny-all" for server.yml, anonymous publish dey refused, and na so e suppose be. Send credentials with -u user:pass or -H "Authorization: Bearer tk_...". If you already dey send token and you still get 403, the user behind that token no get matching ACL entry for the topic. Run ntfy access to print the complete list. Remember say write grant no allow subscribing, so account wey fit publish well still go dey refused when e try read the same topic.

Notifications dey work for iPhone with self-hosted ntfy server?

Dem dey work, through relay wey you no fit avoid. Apple dey wake apps only through APNs (Apple push notification service), and na only the app publisher fit send to am, so ntfy forwards a poll_request wey contain the message ID go ntfy.sh, and ntfy.sh relay am go the device. Set upstream-base-url: "https://ntfy.sh" for server.yml and restart the container. The message body itself still dey fetched from your server. Without that setting, iOS notifications go delay or no appear at all.

Why my cron job's ntfy alert no ever arrive?

Run the curl line by itself first to confirm say the token and topic correct. If e work by hand but no work from cron, the problem dey before the alert: cron dey run jobs with minimal environment and short PATH, so script wey call command by bare name fit stop before e reach the curl line. Use absolute paths, redirect the job output go log file, and read that file after the next run. 429 response instead of delivery mean say the rate limit dey work and your script dey retry too fast.

I suppose expose ntfy for public internet?

The phone apps need reach am from mobile networks, so public HTTPS endpoint with auth-default-access: "deny-all" and per-topic ACLs na the normal setup, and e safe as long as no topic readable by everyone. VPN-only instance make sense when every subscriber na machine wey you control. E no suit phones well, because the app go receive only while the tunnel dey up, so alerts go queue until the phone reconnect.