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:
- WebSocket — ntfy uses WebSocket for real-time subscriptions. Caddy proxies it automatically, but if you're behind nginx, you need to explicitly configure the
UpgradeandConnectionheaders. - 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:
behind-proxy: trueis set in the ntfy config- Cloudflare isn't blocking WebSocket (Free plan doesn't block it)
- 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.