All articles
TutorialsJun 04, 2026 · 23 min read

Self-Host ntfy on a VPS for Push Notifications

Self-Host ntfy on a VPS for Push Notifications

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.

TL;DR

  • Install Docker and Docker Compose on a fresh VPS
  • Point a subdomain like ntfy.example.com at your server
  • Run ntfy and Caddy with one docker-compose.yml
  • Set auth-default-access: deny-all so topics are private by default
  • Create an admin user, a publishing user, and grant per-topic access
  • Publish with curl and subscribe from the Android/iOS app
  • Wire it into backup scripts, disk-space checks, and Uptime Kuma

Total time: about 20 minutes.

What You Need

  • A VPS with at least 512 MB RAM running Ubuntu 22.04 or 24.04
  • A domain you can add DNS records to
  • Ports 80 and 443 open to the internet (Let's Encrypt requires this)
  • Root or sudo access

ntfy is featherweight. It idles around 15 MB of RAM and the SQLite-backed message cache barely touches the disk.

Step 1: Point a Subdomain at Your VPS

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.

Step 2: Install Docker and Docker Compose

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

Step 3: Open the Firewall

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.

Step 4: Create the Project Directory

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 config
  • cache/ - the message cache database (short-lived messages)
  • lib/ - the user and access-control database (the one you can't lose)

Step 5: Write the ntfy Config

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).
  • The attachment-* lines let you push files and screenshots; drop them if you only need text.
`auth-default-access: deny-all` is not optional for an internet-facing server. Leave it on the default `read-write` and your ntfy box becomes an open relay - anyone can spam your phone or read your alerts by guessing a topic name. Set it now, before you ever start the container.

Step 6: Write the Compose File

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.

Step 7: Write the Caddyfile

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.

Step 8: Start the Stack

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.

Step 9: Create Users and Lock Down Access

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.

Use a token instead of a password for scripts

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.

Step 10: Send Your First Notification

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

Step 11: Install the Mobile and Desktop Apps

Subscribing is the other half. Install the app:

  • Android - from Google Play or F-Droid.
  • iOS - from the App Store.
  • Desktop / browser - the web app at 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.

iOS push delivery is the one quirk of self-hosting. Apple only allows instant pushes through APNS, which a private server can't reach directly. The `upstream-base-url: "https://ntfy.sh"` line from Step 5 fixes this: your server sends a tiny poll request through ntfy.sh to wake the phone, which then fetches the actual message from your server. Only a hash of the topic name reaches ntfy.sh, never the message body - but if you skip this setting, iOS notifications will be delayed or silently dropped.

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.

Step 12: Wire It Into Your Servers

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.

Step 13: Back Up the Data

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.

Step 14: Upgrade Safely

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.

Troubleshooting

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.

Going Further

  • Make one topic public. For a status feed anyone can subscribe to, grant ntfy access everyone announcements read-only while leaving everything else private.
  • Enable browser web push. ntfy supports VAPID-based web push for background browser notifications - generate keys with ntfy webpush and add the web-push-* keys to your config.
  • Trigger workflows, not just pings. ntfy can subscribe a command to a topic (ntfy subscribe), so an incoming message runs a script - a lightweight remote trigger for restarts or deploys.
  • Keep it private end to end. If these alerts never need to reach the open internet, put the whole server behind a VPN with our Tailscale guide and skip the public DNS record entirely.
  • Harden the box. A notification server is only as trustworthy as the host under it - lock down SSH with our Fail2Ban and key-only auth guide.

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.