SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Self-host ntfy for server push alerts

Run ntfy on your own VPS behind TLS with Docker Compose. Lock topics with users and ACLs, then alert from cron and systemd OnFailure units.

What a self-hosted ntfy server does

A self-hosted ntfy server turns an HTTP POST into a push notification on your phone. You publish with curl, and the message arrives on the Android app, the iOS app, a browser tab, or anything else that can hold an HTTP connection open. There is no client library to install and no message broker to run.

ntfy addresses messages by topic. A topic is a name in the URL path, like https://ntfy.example.com/alerts, and it exists the moment someone publishes to it. On a default install, anyone who knows that name can read the topic and write to it, which is why the project's own documentation compares a topic name to a password. That model is fine for the public ntfy.sh service. It is not fine for a server carrying your backup failures, so this guide turns authentication on before the first message is ever sent.

What you need before you start

You need a VPS running Ubuntu 24.04 or Debian 13 with Docker Engine and the Compose plugin, a domain name, and very little RAM. Create a DNS (domain name system) A record pointing ntfy.example.com at the server's public IP address, then confirm it resolves before you touch anything else.

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

dig must print your server's IP. Certificate issuance fails if it prints nothing, because the certificate authority checks the name from the outside. Port 80 stays open because ACME (automatic certificate management environment), the protocol behind Let's Encrypt, uses it for the HTTP challenge. The ntfy container itself never gets a public port.

Write the ntfy config file

The Docker image does not contain a config file, so you create one. Every command later in this guide reads from it. First find the user ID and group ID the container will 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 weight. base-url must be the exact public HTTPS address, because ntfy builds attachment links and the web app's own requests from it, so a wrong value gives you a web app that loads and then fails every action. listen-http: ":2586" binds to all interfaces inside the container, which looks careless and is correct: the container has its own network namespace, so binding to 127.0.0.1 there would make the port unreachable from the host and Docker's published port would never connect. auth-default-access: "deny-all" is the entire security posture, because it refuses read and write to anyone without an explicit grant. behind-proxy: true tells ntfy to take the client address from the X-Forwarded-For header, so rate limits count real visitors instead of counting the reverse proxy as one very busy client.

enable-login: true lets the web app and the phone apps sign in with a password. enable-signup stays false, since self-service account creation on a private server is an 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 in /opt/ntfy/compose.yaml, replacing 1000:1000 with the two numbers id -u and id -g printed 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

A healthy server answers {"healthy":true}. Two details in that compose file are deliberate. The image is pinned to v2.27.0, the current release as of August 2026, instead of latest, because with latest the next docker compose pull changes your server version and you find out from the changelog afterwards. The port is published as 127.0.0.1:2586:2586, so the container is reachable only from the host's loopback address. Write 2586:2586 instead and Docker inserts its own firewall rules ahead of yours, which means the port answers from the internet even though ufw status says the port is closed.

If curl prints Connection refused, read the container log. A permission error on /var/lib/ntfy/user.db means the user: line does not match the owner of those directories, so the process cannot create its own database and exits. The Docker Compose basics guide for a VPS covers volume ownership and restart policies in more detail.

Put TLS in front with Caddy

Caddy requests and renews the certificate on its own, which is the shortest route to 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} over HTTPS means the whole path works. A 502 from Caddy means ntfy is not listening: check with sudo ss -lntp | grep 2586. A certificate error usually means the DNS record is wrong or port 80 is blocked, and sudo journalctl -u caddy -n 50 names which one.

If you already run nginx, copy the proxy settings ntfy documents: proxy_http_version 1.1, proxy_buffering off, proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for, and read and send timeouts of at least three minutes. A subscriber holds one HTTP connection open for as long as it is listening, and nginx closes an idle upstream connection after 60 seconds by default, so subscribers reconnect in a loop and messages sent during the gap are missed.

Create users and lock topics down

Authentication is on and nobody has access to anything yet, which is the point. Create one admin account for yourself and one machine account for scripts. These commands read /etc/ntfy/server.yml from inside the container, which is why the config file is a 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 one prompts for a password. An admin ignores the access list and can read and write every topic, so keep that account for yourself and the phone app. robot is a plain user with no access at all 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

An ACL (access control list) entry is a user, a topic and a permission. The topic is either a literal name or a pattern where * matches anything, so alerts_* covers alerts_backup and alerts_db without one command per host. The permission write means publish only, so a token stolen from a cron job cannot subscribe and read back what it sent. The special username everyone sets what an unauthenticated visitor may do, and you would use it only to open something deliberately public, like ntfy access everyone status read.

Scripts should carry a token, not your password.

sudo docker compose exec ntfy ntfy token add robot

The command prints a token starting with tk_. A token inherits exactly the access of the user it belongs to, so this one can publish to the alerts topics and do nothing else. ntfy token list shows what exists, and ntfy token remove revokes one without touching the user's password.

Send your first message and prove the lock works

Start by checking that the door is shut.

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

That prints 403, and 403 is the correct answer: auth-default-access: "deny-all" refuses an anonymous publish. Now send a real one.

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 replies with the stored message as JSON, which is how you know it was accepted rather than swallowed. Title is the bold first line. Priority runs from 1 to 5, or by name from min to urgent, and it decides whether the phone makes a sound. Tags become emoji on the notification when the name matches a known emoji short code, and stay as plain text when it does not.

To watch a topic from a terminal, stream it:

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

curl prompts for the password. Each message arrives as one line, and the blank lines that appear now and then are keepalives. Opening https://ntfy.example.com in a browser and signing in with the same account gives you the web app version of the same stream.

Set rate limits so one script cannot flood the server

By default each visitor gets a bucket of 60 requests, refilled at one request every 5 seconds. That is generous for a private server, and a script stuck in a retry loop will use all of it. 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

A visitor over the limit gets HTTP 429 instead of a delivered message. The limit is counted per visitor address, which is the reason behind-proxy: true matters so much: without it ntfy sees only Caddy's address, every client counts as the same visitor, and one noisy script exhausts the bucket that your phone and your other servers share.

Alert from a cron job that fails

Keep the token out of the command line. ps aux shows the full command line of every running process to every user on the box, so a token passed with -H is readable by any local account for as long as curl runs. A curl config file avoids that.

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 this as /usr/local/bin/backup-with-alert.sh and chmod 750 it.

#!/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

$? is captured on the line immediately after the command, because the next command run would overwrite it. The output goes through tail -c 1000 because ntfy enforces a maximum message size and a notification is not a log viewer. The closing exit "$code" preserves the original status, so anything else watching this job still sees a failure. Test the whole thing by pointing the script at /bin/false for one run.

A failure branch that never executes is worse than no alerting, because it looks like silence means success. Cron gives your job a nearly empty environment and a much shorter PATH than your login shell, so a script that works when you run it by hand can die before it ever reaches the curl line. The guide on why a cron job does not run covers those environment traps. Use absolute paths everywhere, and read the log file after the first scheduled run instead of assuming.

Alert when a systemd unit fails

Cron covers scheduled work. Long-running services need OnFailure=, which systemd runs whenever a unit enters the failed state. Create one template unit and reuse it for every service on the box. Save this 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 it to a service with a drop-in, so a package upgrade cannot overwrite your edit.

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

%n expands to the full unit name, so the instance becomes ntfy-unit-failed@myapp.service, and %i inside the template hands myapp.service to the script as its first argument. That is what lets one template serve every unit. Prove it works with a unit that fails 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 exits non-zero and prints Job for ntfy-selftest.service failed because the control process exited with error code, and the phone should buzz about a second later. Delete the test unit afterwards.

One trap deserves attention. OnFailure= runs only when a unit reaches the failed state, and a service with Restart=always may never reach it, because systemd keeps restarting it instead. The unit fails only once it exceeds StartLimitBurst restarts inside StartLimitIntervalSec. Set those two values on any service you want to hear about, or a crash loop will churn quietly for days. Timers are the cleaner replacement for the cron pattern above, since a timer's service unit gets OnFailure= for free, and the guide to systemd services and timers on a VPS walks through converting one.

Wire an uptime monitor into the same topic

Uptime Kuma, the self-hosted status monitor, ships an 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, pick a priority, and paste the robot access token. Send the test notification before you save, because a wrong topic name fails silently on a write grant that does not cover it.

The honest limit of this arrangement: a monitor running on the same VPS cannot tell you that the VPS is down, and ntfy cannot deliver the news that ntfy is down. Run the monitor on a different machine, and give it a second notification channel, such as email, for the monitor that watches ntfy itself. Uptime Kuma's Push monitor type covers the other blind spot: your cron job calls a push URL after a successful run, and Kuma alerts when those calls stop arriving. A failure branch fires only when the job runs, so it says nothing about the job that never started.

Does self-hosted ntfy work on Android and iPhone?

On Android, yes, without qualification. Install the app from Google Play or F-Droid, open Settings, set the default server to https://ntfy.example.com, add your account under the user management screen, then subscribe to alerts. Instant delivery keeps a foreground service running so messages arrive even while the phone is in doze mode, and the permanent notification that comes with it is Android's requirement for foreground services rather than a bug. The F-Droid build contains no Firebase code at all, so every subscription uses instant delivery. ntfy can also act as a UnifiedPush distributor, an open replacement for Google's push service, so other apps that support UnifiedPush can deliver through your server too.

On iOS, it works with one dependency you cannot remove. Apple wakes a backgrounded app only through APNs (Apple push notification service), and only the party holding the app's signing credentials may send to it, so your server has no way to reach the app directly. ntfy solves this with a relay: your server sends a poll_request holding the message ID to ntfy.sh, which forwards it through Firebase and APNs to wake the app, and the app then fetches the message body from your server.

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

Be clear about what that costs. The message content stays on your box, but the fact that a message arrived, and its ID, passes through infrastructure you do not run. Without this setting, notifications on iPhone from a self-hosted server arrive late or not at all, because nothing wakes the app. The only way to remove the relay is to build and ship the iOS app yourself with your own Apple developer account and your own APNs keys, which means an annual fee and a rebuild for every update. If the relay is unacceptable for your use, keep the alerting on Android or on the desktop web app.

Backups, upgrades and pinning the image

Two paths cannot be regenerated: /etc/ntfy/server.yml and /var/lib/ntfy/user.db. The second holds every user, password hash, ACL entry and token, so handle it like a private key.

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

Copy that file off the server. cache.db holds only recent messages, 12 hours of them with the cache-duration above, so losing it costs nothing worth protecting. Upgrading means editing the tag in the compose file and pulling.

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 on start, so rolling back to an older tag after a schema change is not safe. Keep the backup you just took until the new version has run for a day.

Gotify and Apprise

Gotify is the smaller option: one binary with a web UI and an Android app, no topic wildcards and no official iOS client, which suits a private box where Android is the only target. Apprise is a Python library and command line tool rather than a server, and it fans a single message out to more than a hundred services including ntfy, which suits a script that must reach several places at once. ntfy is the one that gives you a server, an HTTP API and apps on both mobile platforms, which is why it is the usual answer for alerting from a rented server.

FAQ

Why does publishing to my ntfy server return 403?

With auth-default-access: "deny-all" in server.yml, an anonymous publish is refused, and that is the intended behaviour. Send credentials with -u user:pass or -H "Authorization: Bearer tk_...". If you are already sending a token and still get 403, the user behind that token has no matching ACL entry for the topic. Run ntfy access to print the full list. Remember that a write grant does not allow subscribing, so an account that publishes fine will still be refused when it tries to read the same topic.

Do notifications work on iPhone with a self-hosted ntfy server?

They work, through a relay you cannot avoid. Apple wakes apps only through APNs (Apple push notification service), and only the app's publisher can send to it, so ntfy forwards a poll_request containing the message ID to ntfy.sh, which relays it on to the device. Set upstream-base-url: "https://ntfy.sh" in server.yml and restart the container. The message body itself is still fetched from your server. Without that setting, iOS notifications are delayed or never appear.

Why did my cron job's ntfy alert never arrive?

Run the curl line on its own first to prove the token and topic are right. If it works by hand but not from cron, the failure is upstream of the alert: cron runs jobs with a minimal environment and a short PATH, so a script that calls a command by bare name can die before reaching the curl line. Use absolute paths, redirect the job's output to a log file, and read that file after the next run. A 429 response instead of a delivery means the rate limit is working and your script is retrying too fast.

Should I expose ntfy on the public internet?

The phone apps need to reach it from mobile networks, so a public HTTPS endpoint with auth-default-access: "deny-all" and per-topic ACLs is the normal setup, and it is safe as long as no topic is readable by everyone. A VPN-only instance is reasonable when every subscriber is a machine you control. It is a poor fit for phones, because the app receives only while the tunnel is up, so alerts queue until the phone reconnects.