Deployment guide

Self-host ntfy on a VPS: push notifications for scripts and CI/CD

Deploy on a VPS Cloud →

Tutorial

Security & Monitoring

Self-host ntfy on a VPS: push notifications for scripts and CI/CD

Slack charges per seat. Emails get lost in the noise. Discord webhooks vanish in a thread. ntfy (Apache 2.0, ~32k ⭐, v2.30.0 — Oct. 2026) takes a different approach: an HTTP pub/sub server you self-host on your VPS, where any script sends an alert with a plain `curl -d`. The mobile app receives the push notification in real time — even off the local network. Single Docker container, < 30 MB RAM, no third-party account required. In this article: deployment, authentication, multi-tenant configuration with `server.yml`, Backrest / Uptime Kuma / cron integrations, SMTP notifications, and the five real errors every admin hits in production.

16 min read8 steps
Contents· The problem: too many monitoring tools, too many notification channels1/14
  1. 01The problem: too many monitoring tools, too many notification channels
  2. 02What ntfy gives you out of the box
  3. 03Architecture: single-container HTTP pub/sub
  4. 04Deploy ntfy in production: 8 steps
  5. 05Authentication and access tokens: never expose a public topic
  6. 06Multi-tenant configuration: one server, multiple teams
  7. 07Tip: use topic names as namespaces and set up a nightly mute strategy
  8. 08Troubleshooting: 5 real production errors
  9. 09iOS: why a self-hosted ntfy goes through ntfy.sh for push
  10. 10iOS checklist: seven checks before digging deeper
  11. 11When nothing arrives anyway: what issues #1305 and #1844 really say
  12. 12Tip: do not trust a critical alert to iOS push alone
  13. 13Advanced integrations: Backrest, Uptime Kuma, Grafana
  14. 14Tip: test the full delivery chain before relying on it

The problem: too many monitoring tools, too many notification channels

Uptime Kuma monitors your services, Beszel monitors your VPS metrics, Backrest manages your backups, and your cron jobs go silent at night — except when they fail. Each has its own notification channel: Slack, email, webhook, Telegram. The result: five different integrations to maintain, five alert formats to read, and no unified view when something breaks at 3 AM. ntfy solves this from the other direction: you host the notification hub, and every tool or script sends its alerts over plain HTTP. A single topic to watch on your phone, zero dependency on a third-party service.

What ntfy gives you out of the box

  • Send a notification with a plain curl — curl -d "Build failed" https://ntfy.example.com/ci-alerts — zero SDK, zero library.
  • Receive on Android (F-Droid / Play Store), iOS (App Store), desktop browser and the ntfy web UI — all subscribe to the same topic via WebSocket or SSE.
  • Notification actions — buttons in the push that trigger HTTP callbacks, open URLs or run commands on the subscriber device.
  • Scheduled notifications — delayed delivery (X-Delay: 30min) to schedule reminders from scripts.
  • Priority levels (min / low / default / high / urgent) with DND bypass for urgent alerts.
  • Attachments — images or files up to 15 MB via the X-Attach header or multipart upload.
  • SMTP notifications — ntfy can send a fallback email if your mobile device is offline or the app is not installed.
  • Granular access control — users, hashed passwords, access tokens and per-topic ACLs via ntfy user and ntfy access.
  • < 30 MB RAM, SQLite (or PostgreSQL for high-throughput deployments), single container — the lightest self-hosted push notification server in its class.

Architecture: single-container HTTP pub/sub

ntfy is a Go binary that runs in a single Docker container. It exposes a simple HTTP interface: PUT or POST to /your-topic publishes a message; any client listening to that topic (via WebSocket, SSE or the mobile app) receives it instantly. Messages are cached in SQLite (/var/lib/ntfy/cache.db) with configurable retention (12 hours by default). In production, ntfy is expected to sit behind an nginx reverse proxy that handles TLS — behind-proxy: true in server.yml tells ntfy to read the real client IP from X-Forwarded-For. All advanced configuration — authentication, SMTP, access control — lives in a single /etc/ntfy/server.yml file mounted as a Docker volume.

Deploy ntfy in production: 8 steps

  1. Deploy ntfy from the ServOrbit marketplace

    Open your ServOrbit dashboard → Marketplace → Monitoring → ntfy → Deploy. Docker pulls binwiederhier/ntfy:v2.30.0, starts ntfy on port 8080, and the web UI is accessible within 5 seconds. An nginx reverse proxy with a Let's Encrypt certificate is automatically configured on your chosen domain.

  2. Send your first notification

    From any terminal with curl:
    curl -d "Hello from my VPS" https://ntfy.example.com/my-topic
    Open the ntfy web UI at the same address, navigate to my-topic and your message appears live. Topics are created on first publish — no prior setup needed.

  3. Create the full server.yml

    Mount a configuration file into the container. Here is a production server.yml:
    base-url: https://ntfy.example.com
    listen-http: ":8080"
    behind-proxy: true
    # SQLite cache (12h)
    cache-file: /var/lib/ntfy/cache.db
    cache-duration: 12h
    # Authentication: nobody can read or write without an account
    auth-file: /var/lib/ntfy/user.db
    auth-default-access: deny-all
    # Outbound SMTP — ntfy sends fallback emails
    smtp-sender-addr: smtp.example.com:587
    smtp-sender-user: [email protected]
    smtp-sender-pass: smtpPassword
    smtp-sender-from: [email protected]
    Restart the container after any change to this file: docker restart ntfy.
    To get notifications on iOS as well, add upstream-base-url: "https://ntfy.sh" to this file: the "iOS" section below explains why.

  4. Create users and access tokens

    Access tokens let your scripts publish without exposing a password:
    # Create an interactive admin user
    docker exec -it ntfy ntfy user add --role=admin admin
    # Create a dedicated user for scripts
    docker exec -it ntfy ntfy user add scripts
    # Generate an access token for that user
    docker exec -it ntfy ntfy token add scripts
    # Output: tk_AbCdEf123456...
    # Grant that user read/write on a specific topic
    docker exec -it ntfy ntfy access scripts server-alerts rw
    The token tk_AbCdEf123456 replaces the password in all your scripts.

  5. Subscribe on your phone with authentication

    Install the ntfy app (Android: F-Droid or Play Store; iOS: App Store). Tap + → enter your server URL → the topic name. When ntfy asks for credentials, enter the admin username and password (or a dedicated read-only account). You will receive push notifications as soon as anything is published — via UnifiedPush (Android F-Droid), FCM (Android Play) or APNs (iOS). On iOS, instant push only arrives if your server has upstream-base-url set (see the dedicated section below).

  6. Alert from cron jobs using a token

    Use the access token in the Authorization header:
    #!/bin/bash
    NTFY_URL="https://ntfy.example.com"
    NTFY_TOKEN="tk_AbCdEf123456"
    /usr/local/bin/backup.sh
    STATUS=$?
    if [ $STATUS -eq 0 ]; then
    curl -s -H "Authorization: Bearer $NTFY_TOKEN" -H "Title: Nightly backup" -H "Priority: default" -H "Tags: white_check_mark" -d "Backup completed at $(date)" "$NTFY_URL/cron-jobs"
    else
    curl -s -H "Authorization: Bearer $NTFY_TOKEN" -H "Title: Backup FAILED" -H "Priority: urgent" -H "Tags: warning" -d "Backup failed — exit code $STATUS" "$NTFY_URL/cron-jobs"
    fi

  7. Configure Backrest and Uptime Kuma

    Backrest: in the Backrest UI → Plans → your plan → Hooks → Add Hook → type Webhook (ntfy) → enter the URL https://ntfy.example.com/backups and the Bearer token. Backrest notifies on every completed or failed backup with snapshot stats.

    Uptime Kuma: Settings → Notifications → New → type ntfy → Server URL: https://ntfy.example.com → Topic: server-alerts → Username / Password. Test the notification from the UI before saving.

    Grafana: Contact points → New → type webhook → URL https://ntfy.example.com/grafana → HTTP headers: Authorization: Bearer tk_AbCdEf123456 + Content-Type: text/plain.

  8. Enable fallback email notifications (SMTP)

    Once smtp-sender-addr is set in server.yml, send a notification with the X-Email header:
    curl -H "Authorization: Bearer $NTFY_TOKEN" -H "Title: Critical alert" -H "X-Email: [email protected]" -d "Disk full at 95%" https://ntfy.example.com/server-alerts
    ntfy sends both the push notification and an email. Useful when the mobile app is not installed, or to archive incidents in your inbox.

Authentication and access tokens: never expose a public topic

By default, ntfy starts with no access control (the docs give auth-default-access as read-write) — anyone who knows the URL can read and write. In production, three layers of protection stack up: first auth-default-access: deny-all locks out all unknown requests; then users created with ntfy user add with bcrypt-hashed passwords stored in user.db; finally access tokens (ntfy token add) tied to a specific user and revocable independently of the password. A compromised token can be revoked without changing the user's password. Per-topic ACLs (ntfy access) provide compartmentalization: the backrest user can only write to backups and never sees server-alerts.

Multi-tenant configuration: one server, multiple teams

ntfy supports multiple namespaces on the same server through topic naming conventions. Create one user per service or team, grant rights only to that user's topics, and each service believes it has its own dedicated hub. The admin role sees everything; regular user role accounts are scoped to their ACLs. For teams that must be fully isolated (separate billing, separate data), deploy two ntfy containers on two distinct subdomains.

Tip: use topic names as namespaces and set up a nightly mute strategy

Organise your alerts by source: ci-builds for GitHub Actions/GitLab CI, server-alerts for Uptime Kuma/Beszel, cron-jobs for batch scripts, backups for Backrest. The ntfy app lets you mute specific topics at night (e.g., cron-jobs) while keeping server-alerts at maximum priority with DND bypass. No server-side configuration — just the topic name in the curl command and the app's notification preferences.

Troubleshooting: 5 real production errors

1. HTTP 403 Forbidden on publish or subscribe
Message: {"code":40301,"http":403,"error":"Unauthorized"}
Cause: auth-default-access: deny-all is active and the token is missing or expired. Check: curl -v -H "Authorization: Bearer tk_..." https://ntfy.example.com/my-topic. Regenerate the token with ntfy token add <user> and update your scripts.

2. Notification sent but never received on mobile
On Android, first check that the app is subscribed to your instance and not to the public ntfy.sh server (URL in the app settings). On iOS, the most likely cause is a missing upstream-base-url: "https://ntfy.sh" in server.yml: without this relay, the docs warn that delivery can take hours. Set it, then run through the checklist in the iOS section below. base-url must also be set, but the docs tie it to attachment URLs and email footers, not to push delivery.

3. Wrong links in outbound emails
SMTP emails sent by ntfy contain links back to the notifications. Without base-url in server.yml, the email footer (link back to the notification) is built without the public URL and is unusable externally. Fix: set base-url: https://ntfy.example.com and restart the container.

4. Repeated WebSocket drops in production (reconnects every 30 s)
Cause: nginx keepalive timeout is shorter than ntfy's heartbeat interval (keepalive-interval: "45s" in the docs' example configuration). The proxy closes the WebSocket before ntfy can send its heartbeat. Add to the nginx location block: proxy_read_timeout 60; and proxy_send_timeout 60;. Also verify that proxy_http_version 1.1; and proxy_set_header Upgrade $http_upgrade; are present — without them, WebSocket degrades to SSE long-polling.

5. OOM (Out of Memory) on a high-throughput topic
ntfy buffers recent messages in memory before writing to SQLite. On a topic receiving hundreds of messages per second (e.g., unfiltered real-time logs), the in-memory buffer can exhaust a 512 MB VPS. Immediate fix: reduce cache-duration to 1h or 30m. Structural fix: never use ntfy as a real-time log sink — filter at the script level (| grep -E 'ERROR|WARN') and push only actionable events. For high-throughput deployments, ntfy can use PostgreSQL instead of SQLite for its stores (see the configuration docs).

iOS: why a self-hosted ntfy goes through ntfy.sh for push

On iOS an app cannot keep a connection open in the background: the ntfy documentation explains that this restriction makes instant push impossible without a central server. For every message, your self-hosted server therefore forwards a "poll" request to an upstream server, ntfy.sh by default, which takes care of waking the app. The setting is upstream-base-url: "https://ntfy.sh". That request carries only the message ID (X-Poll-ID header) and the SHA-256 hash of the topic URL, never the text: the self-hosted server literally sends "New message", and the app then fetches the real content from your server. Without upstream-base-url, the documentation warns that notifications eventually arrive, but delivery can take hours (20 to 30 minutes if the phone is actively in use). upstream-access-token is optional: the docs only reserve it for when the upstream server's limits are exceeded or when it is protected.

iOS checklist: seven checks before digging deeper

  • base-url — set to the public HTTPS URL of your server, the one your phone can reach.
  • upstream-base-url — present in server.yml (or as an environment variable), value https://ntfy.sh, container restarted.
  • The app's default server — in the iOS app settings, your server URL is the one declared as the default server.
  • Subscribed to the right server — the topic was added by choosing your server, not ntfy.sh: a topic with the same name on the public server is a different topic.
  • Upstream token — upstream-access-token is only for when the relay's limits are reached; without an established cause, do not add it by reflex hoping it fixes things.
  • Server logs — check that the message is published and then forwarded to the relay. If it is, your side works and the problem sits between the relay, Apple and the phone.
  • Phone settings — Background App Refresh and Notifications enabled for the app, and no DNS blocker (AdGuard Home, Pi-hole) filtering ntfy.sh.

When nothing arrives anyway: what issues #1305 and #1844 really say

These two issues in the ntfy repository describe the symptom you are chasing: a self-hosted server, an iOS push that does not show, messages that appear only when you refresh the app by hand. Read on 30 October 2026, both are open and neither names a confirmed cause.

#1305 "No push notifications on ios?" (opened in March 2025, labels ios and bug). The reporter gets no banner, neither with ntfy.sh nor with their own instance. Comments say reinstalling the app helped, others that the problem came back months later. A contributor proposes that the app re-subscribes to the Firebase topics at launch and opened a draft PR, stating they could not test it. A maintainer asks the reporter whether the Firebase subscription is the cause, without concluding.

#1844 "No iOS Push notification, also with valid subscription" (opened in July 2026, labels ios, server and bug). The reporter runs ntfy in Docker with NTFY_UPSTREAM_BASE_URL set to https://ntfy.sh and NTFY_UPSTREAM_ACCESS_TOKEN filled in. They say they hit the free limit of 250 messages per day, then took a paid subscription. Their logs show the message published and sent to the relay, and still no push. A contributor answers that 429 errors can lead to an IP ban of up to 4 hours, notes that the reporter's curl test succeeds (so no ban), and that the account showed 48 messages sent out of 2,500. They add that iOS notifications are finicky without anyone knowing exactly why. Reported workaround: switch "Background App Refresh" and "Notifications" off and on again in the iOS settings. The author writes they do not know why this works; the problem came back, then went away with no known cause, and another user confirms the workaround in September 2026. Another report ties a similar case to DNS blocklists.

What you can conclude, and what you cannot. Neither a daily message limit nor a 429 block is established as the cause in these threads: the 429 is ruled out for the reporter's IP, and the 250-message limit is a figure given by the reporter, not by the configuration documentation. The upstream token does not fix the problem there. Treat the list above as checks, not as a guaranteed fix.

Tip: do not trust a critical alert to iOS push alone

As long as these issues stay open, double the alerts that matter: add the X-Email header (see the SMTP step) to also get an email, or keep a second channel on the phone. Contributors in both issues also suggest the PWA (web push) as an alternative to the iOS app. And test end to end after each app or iOS update, with the watchdog heartbeat described above.

Advanced integrations: Backrest, Uptime Kuma, Grafana

Backrest natively supports ntfy as a hook type: configure the topic URL and Bearer token in the Backrest UI, and every completed or failed snapshot automatically generates a push notification with the snapshot name, deduplicated size, and duration. Zero scripting required — the integration is built into the tool.

Uptime Kuma supports ntfy natively in its notification manager. Configuration takes under a minute. Each monitor (HTTP, TCP port, ping, TLS certificate) can have multiple notification channels — combine ntfy for mobile push and ntfy's SMTP for auditability.

Grafana uses generic webhooks. Create a contact point of type webhook, point it at https://ntfy.example.com/grafana, add the Authorization: Bearer tk_... and Content-Type: text/plain headers. Grafana alerts (CPU > 90%, P99 latency, disk > 80%) arrive immediately on your phone with the rule name in the notification title.

Tip: test the full delivery chain before relying on it

Before wiring your infrastructure to ntfy, test the complete chain: send a notification with curl, confirm it appears in the web UI, verify it arrives on the mobile app, and check that the SMTP fallback email lands in the inbox. A silently non-delivering ntfy is worse than no monitoring at all — you believe you are being notified, but you are not. Schedule a */30 * * * * cron that sends a heartbeat to a watchdog topic — if the mobile app does not receive this heartbeat within 30 minutes, ntfy itself is down.

Your notification hub under your control

Deploy ntfy on a ServOrbit VPS and centralise all your monitoring, CI/CD and cron alerts in one self-hosted notification hub — no SaaS, no subscription.