Self-Hosted ntfy: Your Own Push Notification Server

How to set up your own ntfy push notification server: Caddy reverse proxy, access tokens, integration with an AI agent via webhooks. Monitoring, alerts, and an infinite loop trap.

Self-Hosted ntfy: Your Own Push Notification Server

I run an AI agent (Hermes), monitoring tools, a blog, and a bunch of small services on my server. I needed to receive notifications from all this infrastructure on my phone — not via the Telegram Bot API (with its rate limits and dependency on Telegram's servers), but through my own channel that I control.

The solution is ntfy: a lightweight Go server, HTTP pub-sub, Android/iOS apps, and zero vendor lock-in. Here's how I set it up and what I learned.

Installation

On Debian/Ubuntu, it's straightforward:

apt-get install -y ntfy

The package creates an _ntfy user, a systemd service, and a default config. But the "default" listens on :80 without authentication. For production, this needs to change.

Configuration

File /etc/ntfy/server.yml:

base-url: "https://ntfy.example.com"
listen-http: "127.0.0.1:8080"
behind-proxy: true
cache-file: /var/cache/ntfy/cache.db
cache-duration: "24h"
auth-file: /var/lib/ntfy/user.db
auth-default-access: "deny-all"
log-level: info

Let's break down the key points:

listen-http: "127.0.0.1:8080" — listen only on localhost. All external traffic goes through the reverse proxy. If you set 0.0.0.0:80, ntfy will be accessible directly, bypassing SSL and rate limiting.

behind-proxy: true — mandatory if you're behind Caddy/nginx. Without this, ntfy won't see subscribers' real IP addresses (it will see 127.0.0.1), and rate limiting won't work correctly.

auth-default-access: "deny-all" — everything is closed by default. Without this, anyone who guesses a topic name can read from and write to it. This is fine for the public ntfy.sh, but not for self-hosted.

Pitfall: Don't write the config via cat > /etc/ntfy/server.yml in the terminal — some security scanners block heredocs in bash. Use Python instead:

import pathlib
pathlib.Path('/etc/ntfy/server.yml').write_text(config_yaml)

Directory Permissions

The packaged _ntfy user must have write permissions to /var/cache/ntfy and /var/lib/ntfy:

mkdir -p /var/cache/ntfy /var/lib/ntfy
chown _ntfy:_ntfy /var/cache/ntfy /var/lib/ntfy

Without this, the service will crash with "unable to open database file". Classic.

Caddy Reverse Proxy

I have Caddy listening on :80 (Cloudflare handles SSL termination externally). Config for ntfy:

@ntfy host ntfy.example.com
handle @ntfy {
    reverse_proxy localhost:8080
}

Two nuances:

  1. WebSocket — ntfy uses WebSocket for real-time subscriptions. Caddy proxies it automatically, but if you're behind nginx, you need to explicitly configure the Upgrade and Connection headers.
  2. Cloudflare SSL:Flexible — Cloudflare terminates SSL at its edge, and plain HTTP goes to the origin. That's why Caddy listens on :80, not :443. If you switch to SSL:Full, you'll also need to configure a certificate on the origin — overkill for a self-hosted hobby project.

Users and Tokens

Create the first user:

ntfy user add --role=admin admin

The password is requested interactively. After this, all requests require authentication (due to deny-all).

For programmatic access (scripts, AI agent), it's better to use tokens instead of login/password:

ntfy token add admin

The token looks like tk_abc123.... It is passed in the header `Authorization: Bearer ***

Publishing Notifications

Sending a message is a single HTTP POST:

curl -u admin:password \
  -d "Server overheated! CPU 95%" \
  https://ntfy.example.com/monitoring

Or with a token:

curl -H "Authorization: Bearer *** \
  -d "Server overheated!" \
  https://ntfy.example.com/monitoring

With headers for customization:

curl -H "Authorization: Bearer *** \
  -H "Title: Monitoring" \
  -H "Priority: high" \
  -H "Tags: warning,fire" \
  -d "CPU 95%, RAM 90%" \
  https://ntfy.example.com/monitoring

Subscribing to Notifications

CLI:

ntfy subscribe ntfy.example.com/monitoring

HTTP (long-polling):

curl -s ntfy.example.com/monitoring/json

WebSocket (for integrations):

const ws = new WebSocket('wss://ntfy.example.com/monitoring/ws');
ws.onmessage = (e) => console.log(JSON.parse(e.data));

Android/iOS app — just add the server https://ntfy.example.com and subscribe to topics.

Integration with Hermes: Webhooks

The most interesting part is connecting ntfy to an AI agent. Hermes has a webhook system, and ntfy supports actions — automatic HTTP requests triggered when a message is received.

In /etc/ntfy/server.yml:

actions:
  - action: "webhook"
    label: "Forward to Hermes"
    url: "http://localhost:8644/webhooks/ntfy"
    headers:
      Authorization: "Bearer my-hermes-secret"
    body: |
      {
        "topic": "{{ .Topic }}",
        "message": "{{ .Message }}",
        "title": "{{ .Title }}",
        "sender": "{{ .Sender }}",
        "time": "{{ .Time }}"
      }
    topic: "hermes"

Now every message in the hermes topic is automatically forwarded to the Hermes API. The agent can process the notification and respond.

Trap: If Hermes replies to the same hermes topic, ntfy triggers the webhook again, Hermes replies again — an infinite loop. Solution: reply to a different topic (e.g., hermes-responses), or filter by sender/headers in the handler.

Real-World Use Cases

Server Monitoring

A cron script checks CPU, RAM, and disk every 5 minutes. If a threshold is exceeded, it sends an HTTP POST to ntfy. Priority high, tag warning — and you instantly see the problem on your phone. The script is 5 lines of bash, initialization is a single curl.

Alerts from an AI Agent

Hermes runs a cron job (daily briefing) and sends the result to ntfy:

hermes cron job → Hermes API → POST ntfy.example.com/hermes

The user sees a notification on their phone, opens it, and finds a summary of news, tasks, and service statuses.

Deployment Notifications

A CI/CD pipeline (or simple script) sends deployment status: tag white_check_mark for success, x for failure, priority high for critical errors. One POST request — and you see the result on your phone without opening the CI dashboard.

Why Not Telegram

Telegram Bot API is a great option, and I use it too. But self-hosted ntfy has advantages:

  • No rate limits from Telegram (30 messages/sec per chat)
  • No dependency on Telegram servers (they go down sometimes)
  • Full control over data (notifications don't pass through Telegram)
  • No need for a Bot Token and chat ID — just an HTTP POST
  • WebSocket subscriptions built-in, no polling required

Downsides: no rich content (buttons, inline keyboards), no group chats with history, no E2E encryption. For monitoring and alerts — perfect. As a messenger — no.

Security: What Could Go Wrong

Public Topics

If auth-default-access isn't deny-all, anyone who guesses a topic name can read from it. Topic names are not secrets. ntfy subscribe ntfy.example.com/my-secret-topic is not protection; it's security through obscurity.

Always set auth-default-access: "deny-all" and create users with explicit permissions for specific topics:

ntfy access admin monitoring rw
ntfy access admin hermes rw
ntfy access bot-hermes hermes rw
ntfy access bot-hermes monitoring ro

Rate Limiting

ntfy has built-in rate limiting (default 250 messages per day per visitor). For self-hosted setups with 1-2 users, this is more than enough. But if you send alerts every 10 seconds, you might hit the limit.

Configuration in /etc/ntfy/server.yml:

visitor-subscription-limit: 30
visitor-request-limit-burst: 60
visitor-request-limit-replenish: 5s

Logging

ntfy writes logs to stdout (systemd journal). For production, it's worth configuring logging:

log-level: info
log-format: json

JSON format is easier to parse in Loki/ELK, but text is sufficient for a hobby project.

Migration and Backups

ntfy stores everything in two files:

  • /var/cache/ntfy/cache.db — message cache (SQLite)
  • /var/lib/ntfy/user.db — users and tokens (SQLite)

For backups, simply copy these files. To migrate to another server, transfer the files and config.

Pitfall: When updating ntfy via apt, the config /etc/ntfy/server.yml may be overwritten. Use dpkg --force-confold or back up the config separately.

Troubleshooting

ntfy won't start: "unable to open database file"

The most common issue. Check:

ls -la /var/cache/ntfy /var/lib/ntfy
# Should be owned by _ntfy:_ntfy

journalctl -u ntfy --no-pager -n 20
# Check recent logs

Notifications aren't arriving via Cloudflare

Ensure Cloudflare isn't caching API responses. ntfy API endpoints (/v1/...) should not be cached. In the Dashboard: Caching → Configuration → Cache Level: Bypass for ntfy.example.com/v1/*.

WebSocket won't connect

Behind Cloudflare, WebSocket works, but ensure that:

  1. behind-proxy: true is set in the ntfy config
  2. Cloudflare isn't blocking WebSocket (Free plan doesn't block it)
  3. Caddy/nginx proxies Upgrade headers

"403 Forbidden" when publishing

The user lacks access to the topic. Check:

ntfy access LIST  # Who has access to what
ntfy access admin my-topic rw  # Grant access

Alternatives

Gotify

Similar to ntfy, but written in Go with a different API. Smaller community, fewer apps. If you've already chosen ntfy, there's no reason to switch.

Apprise

A Python library for sending notifications to 80+ services (Telegram, Slack, Discord, ntfy, etc.). Great as a unified sender, but not as a server. Can be combined: Apprise → ntfy → phone.

Shoutrrr

A Go alternative to Apprise. Same approach — a unified interface for various notification backends.

Telegram Bot API

The most obvious option, and I use it too. But ntfy wins for self-hosted setups: no Telegram rate limits, no dependency on Telegram servers, full data control. Telegram is for the messenger experience. ntfy is for infrastructure alerts.

Resources

On a typical VPS, ntfy consumes ~18 MB of RAM and barely loads the CPU. Over 4 hours of operation: 51 messages published, 3 subscribers, 4 active topics. A drop in the ocean.

For comparison: Prometheus + Alertmanager consume ~200 MB. Grafana takes another ~150 MB. ntfy with webhook integration covers 80% of monitoring use cases for a hobby project without all that infrastructure overhead.

Summary

ntfy is fast: from apt install to working push notifications with minimal configuration. A Go binary, a systemd service, an HTTP API. Install it, configure Caddy, create a user — and you have your own notification backend that depends on no one.

For a self-hosted AI agent, it's a must-have. Monitoring, alerts, task notifications — all via one simple protocol.