SSD Nodes Learn Hosting plans →
How to do am Matt ConnorBy Matt Connor · Updated 2026-08-29

How to self-host ntfy with Docker Compose

Run ntfy on Ubuntu 24.04 or Debian 13 behind TLS, lock topics with users and ACLs, then send alerts from cron and systemd OnFailure units.

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 the 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 use topic take address messages. Topic na name for URL path, like https://ntfy.example.com/alerts, and e dey exist as soon as person publish to am. For default install, anybody wey know that name fit read the topic and write to am. Na why the project's own documentation dey compare topic name to password. That model dey okay for public ntfy.sh service. E no dey okay for server wey dey carry your backup failures, so this guide go turn authentication on before anybody send the first message.

Wetin you need before you start

You need 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 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 certificate authority dey check the name from outside. Port 80 remain open because ACME (automatic certificate management environment), wey be protocol behind Let's Encrypt, dey use am for HTTP challenge. The ntfy container itself no get any public port.

ntfy config file Write am

Docker image no carry config file, so you go create one. Every command later for this guide go read from am. First find the user ID and group ID wey 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 settings. base-url must be the exact public HTTPS address, because ntfy dey build attachment links and requests from the web app with am. If the value wrong, web app fit load, then every action go fail. listen-http: ":2586" bind to all interfaces inside the container. This one correct, even though e fit look careless. Container get its own network namespace, so 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" na the full security setting, because e refuse read and write access to anybody wey no get explicit grant. behind-proxy: true tell ntfy make e take client address from the X-Forwarded-For header. This one make rate limits count real visitors instead of counting reverse proxy as one very busy client.

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 access 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 details for that compose file dey intentional. Image dey pin to v2.27.0, wey be current release as of August 2026, instead of latest. Na because with latest, the next docker compose pull go change your server version, and you go only discover am from the changelog afterwards. Port publish as 127.0.0.1:2586:2586, so container fit only reach from host loopback address. If you write 2586:2586 instead, Docker go put im own firewall rules before your own rules. This mean say port go answer from internet even though ufw status talk say port dey closed. Both habits still apply to the next container wey you add: self-hosted RustDesk relay dey pin im image tag the same way, but e no fit hide behind loopback because im signal and relay ports must answer from internet.

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

Put TLS for front with Caddy

Caddy dey request and renew the certificate by itself. This 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 everything inside /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 show 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 normally mean say the DNS record wrong or port 80 dey blocked, and sudo journalctl -u caddy -n 50 go show which one. If you add another service later, you only need one more hostname block for the same Caddyfile. Na this make something like Halcyon, one 90s video store front end for your Jellyfin library fit dey for another subdomain on the same box.

If nginx already dey run for your side, 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, plus read and send timeouts of at least three minutes. Subscriber dey keep one HTTP connection open as long as e dey listen. By default, nginx dey close idle upstream connection after 60 seconds. So subscribers go reconnect again and again, and messages wey dem send during the gap go miss.

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 follow access list, and e fit read and write every topic. So keep that account for yourself and the phone app. robot na plain user wey no get any access until you grant am 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 literal name or pattern where * match anything. So alerts_* go cover alerts_backup and alerts_db without separate command for each host. Permission write mean publish only. This mean say if person steal token from cron job, e no fit subscribe and read wetin e send. Special username everyone set wetin unauthenticated visitor fit do. You go use am only when you deliberately want 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 alerts topics and do nothing else. ntfy token list show wetin dey exist, while ntfy token remove revoke one without touching the user's password.

Send your first message and prove the lock works

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 gree 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 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, and e decide whether the phone go make sound. Tags go turn emoji for the notification when the name match known emoji short code, and dem go remain plain text when e no match.

To watch a topic from terminal, stream am:

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

curl go prompt for the password. Each message go come as one line, and the blank lines wey dey 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, every visitor get bucket of 60 requests, and e dey refill one request every 5 seconds. This one plenty 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 wey deliver. The limit dey count per visitor address. Na why behind-proxy: true matter well well: without am, ntfy go see only Caddy address. Every client go count as the same visitor, and one noisy script go empty the bucket wey your phone and your other servers dey share.

Alert from a cron job wey fail

No put the token for command line. ps aux dey show the complete command line of every process wey dey run to every user for the server, 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 problem.

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" preserves the original status, so anything else wey dey monitor this job still go see failure. 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 environment wey almost empty, plus much shorter PATH than your login shell. So script wey work when you run am by hand fit die before e reach the curl line. The guide on why a cron job no dey run explain those environment traps. Use absolute paths everywhere, and read the log file after the first scheduled run instead of assuming.

Alert wen 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 fit overwrite your edit.

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

%n dey expand to the full unit name, so the instance go become ntfy-unit-failed@myapp.service, and %i inside the template dey hand myapp.service to the script as e first argument. Na this make one template fit serve every unit. Test say e dey work with unit wey 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 non-zero 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, or crash loop fit dey churn quietly for days. Timers na cleaner replacement for the cron pattern above, because timer service unit dey get OnFailure= automatically, and the guide to systemd services and timers on a VPS explain how to convert one.

Set uptime monitor na the same topic

Uptime Kuma, the 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, because wrong topic name fit fail silently when write grant no cover am.

The honest limit of this arrangement be say: monitor wey dey run for the same VPS no fit tell you say VPS don go down, and ntfy no fit deliver message 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 go call push URL after successful run, and Kuma go alert when those calls stop to arrive. Failure branch go fire only when the job run, so e no talk anything about job wey never start.

Self-hosted ntfy dey work for Android and iPhone?

For Android, yes, no condition. Install the app from Google Play or F-Droid, open Settings, set the default server to https://ntfy.example.com, add your account for the user management screen, then subscribe to alerts. Instant delivery keep one foreground service running, so messages fit 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 dey wake backgrounded app only through APNs (Apple push notification service). Na only the party wey get the app's signing credentials fit send to am, so your server no get way to reach the app directly. ntfy solve this with one relay: your server send 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 wetin this one cost. The message content remain for your box, but the fact say message arrive, plus the message ID, pass through infrastructure wey you no run. Without this setting, notifications on iPhone from self-hosted server fit 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 relay no acceptable for your use, keep the alerting for Android or desktop web app.

Backup, upgrade and image pinning

Two path 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, 12 hours of dem with the cache-duration wey dey above, so if you lose am, you no lose anything important to protect. To upgrade, edit the tag for the 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 dem start, so e no safe to roll back to older tag after schema change. Keep the backup wey you just make until the new version don run for one day. Every service for the box get im own short list of paths wey no fit regenerate, and the comparison of PhotoPrism and Immich work out that list, plus the backup commands wey go with am, for a photo library on the same kind of VPS.

Gotify and Apprise

Gotify na di smaller option: na one binary with web UI and Android app. E no get topic wildcards or official iOS client. E good for private box wey Android na di only target. Apprise na Python library and command line tool, e no be server. E fit send one message go more than one hundred services, including ntfy. E good for script wey need reach several places at once. ntfy na di one wey give you server, HTTP API, and apps for both mobile platforms. Na why people usually use am for alerting from rented server.

FAQ

Why ntfy server dey return 403 when I publish?

With auth-default-access: "deny-all" for server.yml, anonymous publish dey refused, and na the intended behaviour be that. Send credentials with -u user:pass or -H "Authorization: Bearer tk_...". If you dey already send token but you still dey 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 go still 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 dey forward poll_request wey contain the message ID go ntfy.sh, wey go relay am to 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 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 failure dey upstream of the alert: cron dey run jobs with minimal environment and short PATH, so script wey call command by bare name fit die 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.

Make I expose ntfy for public internet?

The phone apps need reach am through 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 too suitable for phones, because the app go receive notifications only while the tunnel dey up, so alerts go queue until the phone reconnect.