Sooner or later every server admin wants the same thing: a message on their phone the moment something happens. A backup finished. A disk filled up. A deploy went out. SSH logged in from a new IP. You can glue this to email, but email is slow, easy to ignore, and a pain to trigger from a shell script. What you actually want is a push notification you can fire with a single curl.
ntfy (pronounced "notify") is exactly that: a tiny pub-sub notification server. You publish a message to a topic with an HTTP request, and every device subscribed to that topic gets an instant push. The public ntfy.sh instance is free and works great, but topics there are world-readable to anyone who guesses the name. Self-hosting gives you private topics, your own auth, and notifications that never leave infrastructure you control.
This guide walks through a production setup on a single VPS: Docker, Caddy for automatic HTTPS, user accounts with per-topic access control, the mobile apps, and real examples wiring it into your backups and cron jobs.
ntfy.example.com at your serverdocker-compose.ymlauth-default-access: deny-all so topics are private by defaultcurl and subscribe from the Android/iOS appTotal time: about 20 minutes.
80 and 443 open to the internet (Let's Encrypt requires this)ntfy is featherweight. It idles around 15 MB of RAM and the SQLite-backed message cache barely touches the disk.
In your DNS provider, add an A record:
ntfy.example.com → YOUR_VPS_IPV4
Add an AAAA record too if you use IPv6. Then verify it resolves:
dig +short ntfy.example.com
The output should be your VPS IP. DNS has to resolve before Caddy can request a certificate.
On a fresh Ubuntu box:
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Verify:
docker --version
docker compose version
If you use UFW:
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
Caddy needs 80 for the ACME HTTP challenge and 443 for HTTPS. Don't expose ntfy's container port directly.
sudo mkdir -p /opt/ntfy
cd /opt/ntfy
sudo mkdir -p etc cache lib caddy-data caddy-config
The layout keeps each kind of state in its own directory:
etc/ - the server.yml configcache/ - the message cache database (short-lived messages)lib/ - the user and access-control database (the one you can't lose)Create /opt/ntfy/etc/server.yml:
base-url: "https://ntfy.example.com"
listen-http: ":80"
cache-file: "/var/cache/ntfy/cache.db"
cache-duration: "12h"
auth-file: "/var/lib/ntfy/user.db"
auth-default-access: "deny-all"
behind-proxy: true
attachment-cache-dir: "/var/cache/ntfy/attachments"
attachment-total-size-limit: "1G"
upstream-base-url: "https://ntfy.sh"
What each block does:
base-url must be the exact public URL, including https://. The web app and link generation break if it's wrong.auth-default-access: deny-all is the most important line in this file. Without it, every topic on your server is readable and writable by anyone on the internet who knows the name.behind-proxy: true tells ntfy to read the real client IP from Caddy's X-Forwarded-For header instead of rate-limiting everyone as a single Docker IP.upstream-base-url is what makes instant iOS notifications work on a self-hosted server (more on that in Step 11).attachment-* lines let you push files and screenshots; drop them if you only need text.Create /opt/ntfy/docker-compose.yml:
services:
ntfy:
image: binwiederhier/ntfy:latest
container_name: ntfy
command: serve
restart: unless-stopped
environment:
TZ: UTC
volumes:
- ./etc:/etc/ntfy
- ./cache:/var/cache/ntfy
- ./lib:/var/lib/ntfy
networks:
- ntfy-net
caddy:
image: caddy:2
container_name: ntfy-caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy-data:/data
- ./caddy-config:/config
networks:
- ntfy-net
networks:
ntfy-net:
ntfy listens on port 80 inside the container only. Caddy reaches it over the internal ntfy-net network, so the container port is never published to the host.
Create /opt/ntfy/Caddyfile:
ntfy.example.com {
reverse_proxy ntfy:80
}
That's the whole file. Caddy requests a Let's Encrypt certificate on first boot and renews it automatically. ntfy uses long-lived streaming connections for live subscriptions, and Caddy handles those without any extra tuning - one of the reasons it's a better fit here than a hand-written nginx config. If you want the full pattern reference, see our Caddy reverse-proxy guide.
cd /opt/ntfy
sudo docker compose up -d
sudo docker compose logs -f
Watch the Caddy logs until you see the certificate being issued, then open https://ntfy.example.com in a browser. You'll get ntfy's web app. It loads, but because of deny-all you can't subscribe to anything yet - that's expected. Time to create users.
With deny-all, nothing works until you grant it. ntfy manages users and per-topic permissions through its CLI, which you run inside the container.
Create an admin (full access to everything) and a regular publishing user:
sudo docker exec -it ntfy ntfy user add --role=admin admin
sudo docker exec -it ntfy ntfy user add alerts
Each command prompts for a password twice. The admin role bypasses all ACLs; the alerts user starts with no access at all.
Now grant the alerts user read-write access to the topics it should use:
sudo docker exec -it ntfy ntfy access alerts "server-alerts" rw
sudo docker exec -it ntfy ntfy access alerts "backups" rw
You can use a wildcard to grant a whole namespace at once:
sudo docker exec -it ntfy ntfy access alerts "web01-*" rw
Check what you've configured at any time:
sudo docker exec -it ntfy ntfy access
Access changes apply immediately - no restart needed. Only edits to server.yml require sudo docker compose restart ntfy.
Putting a password in a cron script is sloppy. Generate an access token for the alerts user instead:
sudo docker exec -it ntfy ntfy token add alerts
This prints a token like tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2. It inherits that user's topic permissions and can be revoked on its own without changing the password. Use it as a bearer token in the Authorization header.
Publishing is just an HTTP POST. The topic is the last path segment of the URL. With the token from the previous step:
curl \
-H "Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" \
-d "Hello from the server" \
https://ntfy.example.com/server-alerts
ntfy reads a handful of headers to make messages richer:
curl \
-H "Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" \
-H "Title: Disk space low" \
-H "Priority: high" \
-H "Tags: warning" \
-d "Root filesystem on web-01 is at 92%." \
https://ntfy.example.com/server-alerts
Priority runs from 1 (min, silent) to 5 (urgent, bypasses Do Not Disturb on most phones). Default is 3.Tags map to emoji on the client - warning becomes an alert triangle, white_check_mark a green check, rotating_light a siren. They're also shown as plain text if there's no matching emoji.Title is the bold headline; the -d body is the message text.If you'd rather authenticate with the username and password directly (handy for a quick one-off), use -u:
curl -u alerts:'your-password' -d "Quick test" https://ntfy.example.com/server-alerts
Subscribing is the other half. Install the app:
https://ntfy.example.com supports notifications directly in modern browsers.In the app, before adding a subscription, open settings and add your server as the default. Then subscribe to a topic (e.g. server-alerts) and, when prompted, enter the alerts username and password so the app can read the private topic.
Android needs no upstream - the app keeps its own background connection to your server for instant delivery. If notifications stop arriving after a while, exempt the app from battery optimization in Android settings.
This is where ntfy earns its keep. A few patterns you'll reuse everywhere.
Notify on backup success or failure. Wrap your backup command so you hear about it either way. This pairs naturally with our BorgBackup and Borgmatic guide or the restic to S3 setup:
#!/usr/bin/env bash
set -euo pipefail
NTFY_URL="https://ntfy.example.com/backups"
NTFY_TOKEN="tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2"
if borgmatic --verbosity -1; then
curl -s -H "Authorization: Bearer $NTFY_TOKEN" \
-H "Title: Backup OK" -H "Tags: white_check_mark" \
-d "Nightly backup finished on $(hostname)." \
"$NTFY_URL" > /dev/null
else
curl -s -H "Authorization: Bearer $NTFY_TOKEN" \
-H "Title: Backup FAILED" -H "Priority: urgent" -H "Tags: rotating_light" \
-d "Backup failed on $(hostname). Check the logs." \
"$NTFY_URL" > /dev/null
fi
Alert when a disk fills up. Drop this in /etc/cron.hourly/disk-alert and make it executable:
#!/usr/bin/env bash
set -euo pipefail
THRESHOLD=85
USAGE="$(df --output=pcent / | tail -1 | tr -dc '0-9')"
if [ "$USAGE" -ge "$THRESHOLD" ]; then
curl -s \
-H "Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" \
-H "Title: Disk almost full" -H "Priority: high" -H "Tags: warning" \
-d "Root filesystem on $(hostname) is at ${USAGE}%." \
https://ntfy.example.com/server-alerts > /dev/null
fi
Route monitoring alerts to your phone. If you run Uptime Kuma, add a notification of type ntfy, point it at https://ntfy.example.com, set the topic and the alerts credentials, and every monitor state change lands as a push. The same trick works for Grafana alerting via a webhook contact point.
The only state you truly can't lose is the user and ACL database in /opt/ntfy/lib. The message cache is short-lived by design. A daily tarball of the whole directory is plenty.
Create /usr/local/bin/ntfy-backup.sh:
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="/var/backups/ntfy"
DATE="$(date +%F)"
mkdir -p "$BACKUP_DIR"
tar -czf "$BACKUP_DIR/ntfy-$DATE.tar.gz" -C /opt/ntfy etc lib
find "$BACKUP_DIR" -name "ntfy-*.tar.gz" -mtime +14 -delete
Make it executable and schedule it:
sudo chmod +x /usr/local/bin/ntfy-backup.sh
echo "30 3 * * * root /usr/local/bin/ntfy-backup.sh" | \
sudo tee /etc/cron.d/ntfy-backup
For off-site safety, push the backup directory to object storage on the same schedule.
ntfy upgrades are boring, which is what you want:
cd /opt/ntfy
sudo docker compose pull
sudo docker compose up -d
Take a backup first. Because the user database is a plain SQLite file in a bind mount, a bad upgrade is a docker compose down, restore the tarball, pin the previous image tag, up -d away.
Caddy can't issue a certificate. DNS hasn't propagated yet, or ports 80/443 are blocked upstream by your provider's firewall. Confirm with dig and check the panel.
Every publish returns 403 Forbidden. The user lacks access to that topic, or you forgot the Authorization header. Run ntfy access to see the grants and confirm the topic name matches exactly - ACLs are case-sensitive.
The web app loads but won't let you subscribe. That's deny-all doing its job. Subscribing to a private topic requires logging in through the app or web UI with a user that has at least read access.
iOS notifications are slow or missing. upstream-base-url isn't set, or the app was installed before you added it. Set it in server.yml, restart ntfy, then remove and re-add the subscription in the app.
Rate-limited under load. ntfy rate-limits per visitor IP. If behind-proxy is off, every request looks like it comes from the Caddy container and shares one bucket. Turn it on and restart.
ntfy access everyone announcements read-only while leaving everything else private.ntfy webpush and add the web-push-* keys to your config.ntfy subscribe), so an incoming message runs a script - a lightweight remote trigger for restarts or deploys.That's the whole stack. A small VPS, a Caddyfile, and twenty minutes get you private push notifications you can fire from any script, with no third party in the loop and no per-message fees.
Looking for a VPS to run lightweight services like this? Our Linux plans include fast NVMe storage, IPv6, and ports 80/443 open by default - plenty for ntfy, Caddy, and the rest of your stack on one box. See the options.