ntfy is a pub/sub notification service you can drive with a single curl command. No SDK, no API key ceremony, no client library. You POST a string to a URL and a push notification lands on your phone.
curl -d "Backup finished" https://ntfy.example.com/alertsThat is the entire integration surface for the simple case. It is also why ntfy has quietly become the default notification layer for people who run their own infrastructure. Cron jobs, monitoring hooks, backup scripts, CI pipelines, and Home Assistant all speak curl.
The public instance at ntfy.sh works fine, but it is a public message bus. Anyone who guesses your topic name reads your notifications. Self-hosting on a RamNode VPS gets you access control, unlimited message retention, and the confidence that your server alerts are not passing through someone else's infrastructure.
This guide covers a production ntfy deployment: the binary install, TLS, authentication, Web Push for browsers, iOS support, rate limiting, and the reverse proxy settings that ntfy specifically needs.
Before you start
Pick the right RamNode plan
ntfy is a single Go binary. It is genuinely tiny.
| Deployment | Recommended specs |
|---|---|
| Personal, a handful of scripts and devices | 1 GB RAM, 1 vCPU, 20 GB SSD |
| Small team, dozens of topics, attachments enabled | 2 GB RAM, 1 vCPU, 40 GB SSD |
| Heavy use, thousands of subscribers, long retention | 4 GB RAM, 2 vCPU, 80 GB NVMe |
The smallest RamNode plan handles a personal deployment without breaking a sweat. Idle memory usage sits in the tens of megabytes.
The one thing that scales is connections. Every subscribed client holds an open HTTP stream or WebSocket. A thousand idle subscribers costs you file descriptors and a little memory, not CPU.
Attachments are the storage variable. If you plan to push screenshots or log files through ntfy, size the disk for it and set an expiry.
DNS
Create an A record:
ntfy.example.com. A 198.51.100.42And AAAA if you have IPv6 from RamNode:
ntfy.example.com. AAAA 2001:db8::1Verify:
dig +short ntfy.example.com ASet reverse DNS in the RamNode control panel to match.
A word about email
ntfy can send notifications to email addresses and receive them via inbound SMTP. RamNode does not permit mail services on their VPS and blocks outbound port 25, so:
- Email publishing (ntfy sending notifications out as email) works if you point it at an external SMTP relay on port 587. Postmark, Mailgun, SES all work.
- Inbound email publishing (emailing a topic to publish to it) requires ntfy to listen on port 25. Do not attempt this on RamNode. Skip that feature.
This guide configures outbound email through a relay and leaves the inbound listener off.
Step 1: Base system preparation
Start from a clean Ubuntu 24.04 LTS install via the RamNode control panel.
apt update && apt upgrade -y
apt install -y curl ca-certificates gnupg ufw fail2ban unattended-upgrades apache2-utils jqHostname
hostnamectl set-hostname ntfy.example.com
echo "127.0.1.1 ntfy.example.com ntfy" >> /etc/hostsAdmin user and SSH hardening
adduser deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deployIn /etc/ssh/sshd_config:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yessystemctl reload sshFirewall
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enableRaise the file descriptor limit
This is the one system tuning that matters for ntfy. Every subscriber holds an open connection. The default 1024 limit will bite you sooner than you expect.
Add to /etc/security/limits.conf:
* soft nofile 65535
* hard nofile 65535And to /etc/sysctl.d/99-ntfy.conf:
fs.file-max = 200000
net.core.somaxconn = 4096
net.ipv4.tcp_max_syn_backlog = 4096sysctl --systemThe systemd unit gets its own limit later.
Automatic security updates
dpkg-reconfigure --priority=low unattended-upgradesStep 2: Install ntfy
ntfy ships an apt repository. Use it rather than Docker for this one. It is a single static binary with a systemd unit, and the packaged install gives you a cleaner upgrade path and a proper service user.
curl -fsSL https://archive.heckel.io/apt/pubkey.txt \
| sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg
sudo install -m 0755 -d /etc/apt/keyrings
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] \
https://archive.heckel.io/apt debian main" \
| sudo tee /etc/apt/sources.list.d/archive.heckel.io.list
sudo apt update
sudo apt install -y ntfyEnable but do not start it yet. You want to configure first.
sudo systemctl enable ntfyVerify the binary:
ntfy --versionStep 3: Generate Web Push keys
If you want browser notifications to work when the ntfy tab is closed, you need VAPID keys for Web Push. Generate them now, before writing the config.
sudo ntfy webpush keysThat prints a public key, a private key, and a suggested config block. Copy all of it. You cannot regenerate these later without invalidating every existing browser subscription, so store them with your other secrets.
Step 4: Configure ntfy
Replace /etc/ntfy/server.yml. The stock file is entirely comments, which is useful reading but not useful as a running config.
# ---------------------------------------------------------------
# Core
# ---------------------------------------------------------------
# The public URL. This must match exactly, including scheme.
# ntfy embeds it in links, in the web app config, and in
# Web Push payloads. A mismatch produces subtle breakage.
base-url: "https://ntfy.example.com"
# Bind to localhost. Caddy on the host faces the internet.
listen-http: "127.0.0.1:2586"
# Tells ntfy to trust X-Forwarded-For for rate limiting.
# Without this, every request appears to come from 127.0.0.1
# and your per-visitor rate limits apply to the whole world
# as a single bucket.
behind-proxy: true
# ---------------------------------------------------------------
# Storage
# ---------------------------------------------------------------
# SQLite message cache. Without this, messages exist only in
# memory and vanish on restart.
cache-file: "/var/lib/ntfy/cache.db"
# How long messages stay retrievable for clients that were
# offline. "12h" is the default. Bump it if your devices are
# frequently offline. Set to "0" to disable caching entirely.
cache-duration: "48h"
# Attachments.
attachment-cache-dir: "/var/lib/ntfy/attachments"
attachment-total-size-limit: "5G"
attachment-file-size-limit: "15M"
attachment-expiry-duration: "6h"
# ---------------------------------------------------------------
# Authentication
# ---------------------------------------------------------------
auth-file: "/var/lib/ntfy/user.db"
# The critical setting. "read-write" makes your server a public
# message bus that anyone can publish to and read from.
# "deny-all" means nothing works until you explicitly grant
# access. Start here.
auth-default-access: "deny-all"
# ---------------------------------------------------------------
# Web Push (browser notifications when the tab is closed)
# ---------------------------------------------------------------
web-push-public-key: "YOUR_PUBLIC_KEY"
web-push-private-key: "YOUR_PRIVATE_KEY"
web-push-file: "/var/lib/ntfy/webpush.db"
web-push-email-address: "you@example.com"
# ---------------------------------------------------------------
# iOS support
# ---------------------------------------------------------------
# The iOS app cannot receive push from an arbitrary server
# because APNs requires an Apple developer certificate that
# only the ntfy.sh project holds. This setting makes your
# server poke ntfy.sh, which pokes APNs, which wakes the app,
# which then fetches the actual message from YOUR server.
#
# Only the topic name is sent upstream. The message body is not.
# Comment this out if you have no iOS clients.
upstream-base-url: "https://ntfy.sh"
# ---------------------------------------------------------------
# Rate limiting
# ---------------------------------------------------------------
# Burst allowance per visitor, then refill at the replenish rate.
visitor-request-limit-burst: 60
visitor-request-limit-replenish: "5s"
# Subscription connections per visitor.
visitor-subscription-limit: 30
# Attachment quota per visitor.
visitor-attachment-total-size-limit: "100M"
visitor-attachment-daily-bandwidth-limit: "500M"
# Skip rate limiting for your own infrastructure. Useful when
# a monitoring host bursts a lot of alerts at once.
# visitor-request-limit-exempt-hosts: "10.0.0.0/8,monitoring.example.com"
# ---------------------------------------------------------------
# Outbound email via external relay
# Port 25 is blocked on RamNode. Use a relay on 587.
# Remove this block entirely if you do not need email delivery.
# ---------------------------------------------------------------
smtp-sender-addr: "smtp.postmarkapp.com:587"
smtp-sender-user: "YOUR_SMTP_USERNAME"
smtp-sender-pass: "YOUR_SMTP_PASSWORD"
smtp-sender-from: "ntfy@example.com"
# ---------------------------------------------------------------
# Behavior
# ---------------------------------------------------------------
# Turn this on if you want ntfy to fetch and cache the web app
# assets rather than serving from disk. Leave default.
enable-signup: false
enable-login: true
enable-reservations: true
# Message size cap. Default 4096 bytes. Raising it above
# 4096 breaks compatibility with some clients.
message-size-limit: "4k"
log-level: "info"
log-format: "text"Lock the file down. It holds your Web Push private key and SMTP credentials:
sudo chmod 600 /etc/ntfy/server.yml
sudo chown ntfy:ntfy /etc/ntfy/server.ymlCreate the data directory:
sudo mkdir -p /var/lib/ntfy/attachments
sudo chown -R ntfy:ntfy /var/lib/ntfyThe settings that actually matter
Three of these deserve emphasis because getting them wrong produces confusing behavior rather than clean failure.
auth-default-access: "deny-all". The ntfy default is read-write. If you leave it, your server is an open relay for notifications. Anyone who finds it can publish to any topic and subscribe to any topic. Since topic names are the only namespace, and people pick predictable ones like alerts and backups, this is not theoretical.
behind-proxy: true. Without it, ntfy sees every request as originating from your reverse proxy's IP. Rate limits then apply globally instead of per-visitor, so one aggressive client rate-limits everyone.
base-url. Must be the exact public URL including https://. ntfy hands this to the web app and embeds it in Web Push subscriptions. Get it wrong and the web interface will load but fail to subscribe, with an error message that does not point at the cause.
Step 5: Harden the systemd unit
Use a drop-in rather than editing the packaged unit, so package upgrades do not clobber your changes.
sudo systemctl edit ntfy[Service]
# Every subscriber holds an open connection.
LimitNOFILE=65535
# Sandboxing. ntfy needs very little.
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
NoNewPrivileges=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
ReadWritePaths=/var/lib/ntfy
# Restart on failure.
Restart=on-failure
RestartSec=5sudo systemctl daemon-reload
sudo systemctl start ntfy
sudo systemctl status ntfyConfirm it is listening and the limit took:
sudo ss -tlnp | grep 2586
grep 'Max open files' /proc/$(pgrep -f 'ntfy serve')/limitsStep 6: Reverse proxy and TLS
ntfy has specific proxy requirements that generic configs get wrong. Subscribers hold long-lived streaming connections. If your proxy buffers responses or times out idle connections, notifications arrive in batches or not at all.
Install Caddy:
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddyReplace /etc/caddy/Caddyfile:
ntfy.example.com {
reverse_proxy 127.0.0.1:2586 {
# ntfy streams. Buffering breaks it.
flush_interval -1
}
# Attachments.
request_body {
max_size 15MB
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
-Server
}
log {
output file /var/log/caddy/ntfy.log {
roll_size 50mb
roll_keep 5
}
}
}flush_interval -1 disables response buffering. This is the single most important line in the file. Without it, Caddy holds streamed notifications in a buffer and your clients see nothing until the buffer fills or the connection closes.
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddyIf you prefer nginx
The equivalent settings, since these are easy to miss:
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name ntfy.example.com;
ssl_certificate /etc/letsencrypt/live/ntfy.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ntfy.example.com/privkey.pem;
client_max_body_size 15M;
location / {
proxy_pass http://127.0.0.1:2586;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket upgrade for the web app.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Do not buffer. This is the one that gets missed.
proxy_buffering off;
proxy_request_buffering off;
# Streaming connections are long-lived by design.
# The default 60s kills them.
proxy_connect_timeout 3m;
proxy_send_timeout 3m;
proxy_read_timeout 3m;
proxy_redirect off;
}
}Verify:
curl -sI https://ntfy.example.com/v1/health
curl -s https://ntfy.example.com/v1/health | jqYou want {"healthy":true}.
Step 7: Set up users and access control
You set auth-default-access: deny-all, so right now nothing works. That is correct. Now grant access deliberately.
Create an admin
sudo ntfy user add --role=admin adminAdmins bypass all ACLs. Create one, use it for management, and do not use it in scripts.
Create service accounts
The pattern that works: one user per publishing system, one user per consuming device class.
# A user for servers that publish alerts.
sudo ntfy user add publisher
# A user for your phone, which reads.
sudo ntfy user add phoneGrant topic access
# publisher can write to anything under alerts.
sudo ntfy access publisher 'alerts*' write-only
# phone can read anything under alerts.
sudo ntfy access phone 'alerts*' read-only
# A specific topic both can use.
sudo ntfy access publisher backups write-only
sudo ntfy access phone backups read-onlyWildcards work with * at the end. alerts* matches alerts, alerts-prod, alerts_db.
Review what you have:
sudo ntfy accessAnonymous read on a public topic
If you want one topic readable without credentials, for a status page or similar:
sudo ntfy access everyone status read-onlyeveryone is the reserved name for unauthenticated visitors.
Tokens instead of passwords
For scripts, tokens are better than passwords. They are revocable individually and do not grant web login.
sudo ntfy token add --label "backup-server" publisherThat prints a token starting with tk_. Use it as a bearer token:
curl -H "Authorization: Bearer tk_yourtokenhere" \
-d "Backup complete" \
https://ntfy.example.com/backupsList and revoke:
sudo ntfy token list publisher
sudo ntfy token remove publisher tk_yourtokenhereStep 8: Verify it works
Publish
curl -u publisher:yourpassword \
-d "Hello from RamNode" \
https://ntfy.example.com/alertsSubscribe from another shell
curl -u phone:yourpassword -s \
https://ntfy.example.com/alerts/jsonLeave that running, publish from the first shell, and the message should appear immediately. If it appears with a delay, or only when you kill the connection, your proxy is buffering. Go back to Step 6.
Confirm the ACL actually denies
curl -d "should fail" https://ntfy.example.com/alertsExpect a 403. If you get a 200, auth-default-access is not set to deny-all or ntfy did not reload the config.
Web app
Open https://ntfy.example.com in a browser, log in as phone, subscribe to alerts, and publish from curl. Grant notification permission when prompted. Close the tab and publish again. If Web Push is configured correctly, the notification still arrives.
Step 9: Actually using it
The point of ntfy is that integration is trivial. A few patterns worth having.
Message with priority, tags, and a title
curl -H "Authorization: Bearer tk_yourtoken" \
-H "Title: Disk space critical" \
-H "Priority: urgent" \
-H "Tags: warning,skull" \
-d "/ is at 94% on web01" \
https://ntfy.example.com/alertsPriority urgent bypasses Do Not Disturb on Android. Use it sparingly or your users will mute the topic and you will have achieved nothing.
JSON publishing
Sometimes cleaner than header stacking:
curl -H "Authorization: Bearer tk_yourtoken" \
-H "Content-Type: application/json" \
-d '{
"topic": "alerts",
"title": "Deploy finished",
"message": "v2.4.1 is live",
"priority": 3,
"tags": ["rocket"],
"click": "https://example.com/deploys/2841"
}' \
https://ntfy.example.comWrap a command and report its outcome
The single most useful pattern. Drop this in /usr/local/bin/notify-run:
#!/usr/bin/env bash
# Usage: notify-run <topic> <command...>
TOPIC="$1"; shift
NTFY_URL="https://ntfy.example.com"
TOKEN="tk_yourtoken"
HOST=$(hostname -s)
START=$(date +%s)
OUTPUT=$("$@" 2>&1)
RC=$?
DURATION=$(( $(date +%s) - START ))
if [ $RC -eq 0 ]; then
PRIORITY="default"
TAGS="white_check_mark"
TITLE="OK on $HOST (${DURATION}s)"
else
PRIORITY="high"
TAGS="x"
TITLE="FAILED rc=$RC on $HOST (${DURATION}s)"
fi
curl -s -H "Authorization: Bearer $TOKEN" \
-H "Title: $TITLE" \
-H "Priority: $PRIORITY" \
-H "Tags: $TAGS" \
-d "$*
${OUTPUT:0:3000}" \
"$NTFY_URL/$TOPIC" > /dev/null
exit $RCchmod +x /usr/local/bin/notify-runThen in cron:
30 2 * * * /usr/local/bin/notify-run backups /home/deploy/backup.shYou now get a notification with the command output whether it succeeds or fails, and the exit code propagates so cron still behaves normally.
Systemd failure notifications
Create /etc/systemd/system/notify-failure@.service:
[Unit]
Description=Notify ntfy on failure of %i
[Service]
Type=oneshot
ExecStart=/usr/bin/curl -s \
-H "Authorization: Bearer tk_yourtoken" \
-H "Title: Service failed: %i" \
-H "Priority: high" \
-H "Tags: rotating_light" \
-d "%i failed on %H" \
https://ntfy.example.com/alertsAttach it to any unit:
sudo systemctl edit some-service[Unit]
OnFailure=notify-failure@%n.serviceAttachments
curl -H "Authorization: Bearer tk_yourtoken" \
-H "Filename: nginx-error.log" \
-T /var/log/nginx/error.log \
https://ntfy.example.com/alertsRespects attachment-file-size-limit and expires per attachment-expiry-duration.
Step 10: Backups
Everything lives in /var/lib/ntfy and /etc/ntfy. The auth database is the part you cannot reconstruct.
Create /home/deploy/ntfy-backup.sh:
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="/home/deploy/backups"
STAMP=$(date +%Y%m%d-%H%M%S)
RETAIN_DAYS=30
mkdir -p "$BACKUP_DIR"
# SQLite databases. Use the sqlite3 .backup command rather than
# copying the file, so you get a consistent snapshot of a live db.
for db in cache user webpush; do
if [ -f "/var/lib/ntfy/$db.db" ]; then
sqlite3 "/var/lib/ntfy/$db.db" ".backup '$BACKUP_DIR/$db-$STAMP.db'"
fi
done
# Config. Contains your Web Push private key.
tar czf "$BACKUP_DIR/ntfy-config-$STAMP.tar.gz" /etc/ntfy
find "$BACKUP_DIR" -name '*-20*' -mtime +$RETAIN_DAYS -deletesudo apt install -y sqlite3
chmod +x /home/deploy/ntfy-backup.shcrontab -e0 4 * * * /home/deploy/ntfy-backup.sh >> /home/deploy/ntfy-backup.log 2>&1Copy the results off the VPS. The cache database is disposable, since it holds undelivered messages. The user database and the config are not.
Attachments are excluded on purpose. They expire in hours by design. If you are backing up attachments you are using ntfy as a file store, which it is not.
Step 11: Maintenance
Upgrading
sudo apt update && sudo apt upgrade ntfy
sudo systemctl restart ntfyRead the release notes for anything touching the auth or Web Push schema. ntfy migrates SQLite automatically on start, so back up first.
Watch the cache database
ls -lh /var/lib/ntfy/cache.dbIt grows with cache-duration. At 48 hours with moderate traffic it stays trivially small. If it is large, either your retention is long or something is publishing far more than you think.
Find out what:
sqlite3 /var/lib/ntfy/cache.db \
"SELECT topic, COUNT(*) FROM messages GROUP BY topic ORDER BY 2 DESC LIMIT 10;"Watch connections
ss -tn state established '( sport = :2586 )' | wc -lIf that number approaches your LimitNOFILE, raise it or look for a client reconnecting in a loop.
Logs
journalctl -u ntfy -fRate limit rejections show up here. If you see them constantly from your own infrastructure, add those hosts to visitor-request-limit-exempt-hosts rather than raising the global limit.
Troubleshooting
Notifications arrive in batches or with long delays. Proxy buffering. flush_interval -1 in Caddy, proxy_buffering off in nginx. This is far and away the most common ntfy deployment problem.
Connections drop every 60 seconds. nginx proxy_read_timeout default. Raise it to 3m as shown.
Everything returns 403. Expected with deny-all until you grant access. Check ntfy access and confirm your ACL entries and that you are authenticating.
Rate limits hitting everyone at once. behind-proxy: true is missing. ntfy is bucketing all traffic under your proxy's IP.
Web app loads but subscribe fails. base-url mismatch. It must be the exact public URL with scheme. Check the browser console, which will usually show a request going somewhere unexpected.
Web Push works, then stops after a config change. You regenerated the VAPID keys. Every existing browser subscription is now invalid and users must resubscribe. Do not regenerate keys.
iOS app gets no notifications. Confirm upstream-base-url: "https://ntfy.sh" is set and your server can reach ntfy.sh outbound. Also confirm the topic is not so long or unusual that the iOS app cannot register it. Note that iOS notification delivery relies on the ntfy.sh relay, so if that service is down, iOS push stops even though your server is fine.
Email notifications do not send. You are on port 587 with a relay, not port 25, right? Test the relay independently with swaks before blaming ntfy.
Cannot bind port 2586. Something else has it, or the systemd sandboxing is too tight. ss -tlnp | grep 2586 and journalctl -u ntfy -n 50.
Wrapping up
You have a self-hosted ntfy on a RamNode VPS with TLS, deny-by-default access control, Web Push, token authentication, and backups.
The value here compounds quickly. Once the endpoint exists, every cron job, deploy script, and monitoring hook you write gets notifications for the cost of one curl line. The notify-run wrapper in Step 9 is the highest-leverage thing in this guide: wrap anything you currently run blind and you will find out about failures the moment they happen instead of the next time you happen to look.
Two things to get right and then forget about: keep auth-default-access on deny-all, and keep the proxy from buffering. Almost every ntfy problem anyone has is one of those two.
