Cost: whatever your VM costs (can be USD 0) · Time: ~45 min · Level: intermediate (Linux, SSH, DNS)
TL;DR
- What you get:
https://ntfy.your-domain.com, private (user and token), with a Let’s Encrypt TLS certificate that renews itself. - What you need: an Ubuntu 24.04 VM with a public IP (any provider), a domain, and access to its DNS.
- How you publish:
curl -H "Authorization: Bearer $TOKEN" -d "Hello" https://ntfy.your-domain.com/alertas. - Fallback: free ntfy.sh receives the failure alert if your server goes down.
- Monitoring: every 45 min the server sends its status (services, RAM, disk, TLS days left).
Index
| # | Chapter | What it covers |
|---|---|---|
| 01 | What ntfy is and when to run your own | Architecture and alternatives (free ntfy.sh, Supporter, self-hosted server) |
| 02 | Free ntfy.sh and its limits | When the public instance is enough |
| 03 | Server requirements | VM, ports, provider firewall |
| 04 | DNS | Unproxied A record; Cloudflare via API |
| 05 | Install ntfy + Caddy | Idempotent script with automatic TLS |
| 06 | Users, permissions and tokens | deny-all, admin, one token per system |
| 07 | Set up the phone app | Android, iOS and the topic-with-/ error |
| 08 | Publish with curl | Title, priority, emojis, scheduled messages |
| 09 | Alerts from Claude Code | Notification hook and alert script |
| 10 | Status report every 45 min | The server reports its own health |
| 11 | Operations | Passwords, tokens, logs, updates, removal |
| 12 | Problems and solutions | Everything that failed and how it was fixed |
Conventions
- Example values: domain
ntfy.your-domain.com, IP203.0.113.10, useradmin, topicalertas, SSH aliasmi-vm. - Placeholders:
<YOUR_TOKEN>,<random-topic>. Never publish tokens or passwords. - Terminal: Linux or WSL2 with
bash,curlandjq. - Data as of: October 2026.
ntfy push-notifications caddy self-hosting claude-code
What ntfy is and when to run your own
In one line
- An HTTP pub/sub server: you publish with a
POSTtohttps://<server>/<topic>and every device subscribed to that topic gets a push notification. - Open source (Apache 2.0 / GPLv2), written in Go, a single binary.
- ntfy.sh is the author’s public instance; anyone can install their own.
How the message reaches the phone
- Script / PC
(curl POST)HTTPS + tokenYour ntfy server
ntfy.your-domain.com - Your ntfy server
ntfy.your-domain.compersistent connection
(instant delivery)Android app - Your ntfy server
ntfy.your-domain.comcontent-free pokentfy.sh (relay) - ntfy.sh (relay)APNsiOS app
| Piece | How it works |
|---|---|
| Publish | POST/PUT to /<topic>. The topic is created on first use |
| Android | The app keeps an open connection (WebSocket or JSON stream) to your server |
| iOS | Apple only allows push from whoever signed the app: your server notifies ntfy.sh (upstream-base-url), which wakes the app via APNs; the app then fetches the content from your server |
| Storage | SQLite with a short cache (24 h here); it is not a persistent queue |
Concepts
| Concept | What it is |
|---|---|
| Topic | A channel. Without authentication, its name acts as the password |
| User | An account with read/write permissions per topic |
| Token | A user’s tk_... credential, ideal for scripts; revocable independently |
| Priority | min to urgent; urgent rings even when the phone is on silent |
| Tags | Emojis by name (warning, rocket) shown in the notification |
Options
| Option | Cost | Private | Maintenance | When |
|---|---|---|---|---|
| Free ntfy.sh | USD 0 | No: anyone who guesses the topic can read it | None | Personal alerts with no sensitive data |
| ntfy.sh Supporter | USD 5 (billing period: see ch. 02) | Yes (3 reserved topics) | None | Privacy without running a server |
| Self-hosted server | The VM’s cost | Yes | Medium | Full control, your own domain, your own limits |
What running your own server means
| You gain | You take on |
|---|---|
| Private topics with user and token | Patches, certificates and monitoring |
| No 250-messages-per-day limit | If the server goes down, no alerts (that is why ntfy.sh stays as a fallback) |
| Your domain | A VM that is always on with a public IP |
ntfy push-notifications pub-sub alternatives
Free ntfy.sh and its limits
Sources: docs.ntfy.sh/publish, Limitations section and ntfy.sh/#pricing (October 2026).
Free-tier limits (per IP, no account)
| Limit | ntfy.sh |
|---|---|
| Messages per day | 250 |
| Burst | 60 requests in a row, then 1 every 5 s |
| Message size | 4,096 bytes (title 1 KB, tags 512 bytes) |
| Attachments | 2 MB each, 20 MB total, deleted after 3 h |
| Emails | 5 per day |
| Subscription connections | 30 |
| Traffic | 200 MB per day |
| Message cache | ~12 h |
| Reserved topics | None |
Paid plans
| Plan | USD | Messages/day | Reserved topics | Attachments |
|---|---|---|---|---|
| Supporter | 5 | 2,500 | 3 | 25 MB |
| Pro | 10 | 20,000 | 10 | 250 MB |
| Business | 20 | 50,000 | 50 | 1 GB |
The page does not say whether the price is monthly or yearly.
Using it well
- A long, random topic name (it acts as a password):
echo "avisos-$(head -c 12 /dev/urandom | od -An -tx1 | tr -d ' \n')"
- Never send sensitive data: anyone who knows the name can read everything.
ntfy.sh’s role alongside a self-hosted server
- Fallback: the status report also publishes there; if your server goes down, the failure alert still arrives.
- iOS relay: required even if you have your own server.
ntfy ntfy-sh limits pricing
Server requirements
The machine
| Requirement | Minimum | Note |
|---|---|---|
| OS | Ubuntu 24.04 (amd64 or arm64) | The commands use apt and systemd |
| RAM | 1 GB | ntfy + Caddy use ~100 MB; add swap if you have 1 GB |
| CPU | Any | A fraction of a vCPU is plenty |
| Disk | ~2 GB free | Message cache and logs |
| Network | Static public IP | Must be on 24/7 |
| Access | SSH with a sudo user | The examples use the alias mi-vm |
Any provider works: Hetzner, DigitalOcean, AWS, a cloud free tier, a home server with a static IP.
Recommended SSH alias in ~/.ssh/config:
Host mi-vm
HostName 203.0.113.10
User ubuntu
IdentityFile ~/.ssh/mi_clave
Ports
| Port | Purpose |
|---|---|
| 22 | SSH |
| 80 | Let’s Encrypt validation (HTTP-01) and redirect to HTTPS |
| 443 | ntfy over HTTPS |
Open them in every layer your provider has:
- The cloud firewall (Security Group, Security List, Hetzner Firewall…).
- The firewall inside the VM. Some images ship
iptableswith a finalREJECTthat blocks everything except SSH; the install script handles it.
Check before installing
ssh mi-vm 'lsb_release -ds; dpkg --print-architecture; free -m | sed -n 2p; sudo iptables -S INPUT'
If you see -A INPUT -j REJECT at the end, the install script opens 80 and 443 before that rule.
Domain
- A subdomain (
ntfy.your-domain.com) with access to create anArecord (chapter 04). - The record must exist before installing Caddy.
ntfy requirements vps firewall
DNS
The record
| Type | Name | Content | Proxy | TTL |
|---|---|---|---|---|
A | ntfy | 203.0.113.10 | No proxy (“DNS only” in Cloudflare) | 300 |
Any DNS provider works; all you need is the A record pointing to the public IP.
Why no proxy
- Proxies like Cloudflare’s cut long idle HTTP connections; the phone app keeps one open at all times.
- Caddy validates the domain with Let’s Encrypt (HTTP-01) directly against the server.
- With a proxy, ntfy would see the proxy’s IP instead of the client’s.
Cloudflare via API
A token with the Zone → DNS → Edit permission on the zone. Keep it in a variable, never in a versioned file:
read -rsp 'Cloudflare token: ' CF_API_TOKEN; echo
H=(-H "Authorization: Bearer $CF_API_TOKEN" -H "Content-Type: application/json")
DOMINIO=your-domain.com; HOST=ntfy.your-domain.com; IP=203.0.113.10
# Verify the token
curl -s "${H[@]}" https://api.cloudflare.com/client/v4/user/tokens/verify | jq -r '.result.status' # active
# Zone ID
Z=$(curl -s "${H[@]}" "https://api.cloudflare.com/client/v4/zones?name=$DOMINIO" | jq -r '.result[0].id')
# Create or update (idempotent)
BODY=$(jq -n --arg n "$HOST" --arg ip "$IP" '{type:"A", name:$n, content:$ip, ttl:300, proxied:false, comment:"ntfy"}')
EX=$(curl -s "${H[@]}" "https://api.cloudflare.com/client/v4/zones/$Z/dns_records?name=$HOST" | jq -r '.result[0].id // empty')
if [ -n "$EX" ]; then
curl -s -X PUT "${H[@]}" "https://api.cloudflare.com/client/v4/zones/$Z/dns_records/$EX" -d "$BODY"
else
curl -s -X POST "${H[@]}" "https://api.cloudflare.com/client/v4/zones/$Z/dns_records" -d "$BODY"
fi | jq -c '{success, r: .result | {name, content, proxied}}'
Expected response:
{"success":true,"r":{"name":"ntfy.your-domain.com","content":"203.0.113.10","proxied":false}}
Check
dig +short ntfy.your-domain.com # 203.0.113.10
Delete
curl -s -X DELETE "${H[@]}" "https://api.cloudflare.com/client/v4/zones/$Z/dns_records/$EX"
dns cloudflare api
Install ntfy + Caddy
Before you start
- A server that meets the requirements.
- The DNS
Arecord created (chapter 04): Caddy needs it to obtain the certificate.
Script: setup-ntfy.sh
Idempotent: it can be run again without breaking anything.
#!/usr/bin/env bash
# Installs ntfy + Caddy (automatic TLS) on Ubuntu 24.04. Runs ON THE SERVER with sudo.
# Variables: NTFY_DOMAIN (required), NTFY_ADMIN (default admin), NTFY_PASSWORD (read by `ntfy user add`).
set -euo pipefail
: "${NTFY_DOMAIN:?NTFY_DOMAIN is missing}" "${NTFY_ADMIN:=admin}"
export DEBIAN_FRONTEND=noninteractive
ARCH=$(dpkg --print-architecture) # amd64 or arm64
# 1. 1 GB swap if there is none (servers with 1 GB of RAM)
if ! swapon --show | grep -q . && [ "$(free -m | awk '/^Mem:/{print $2}')" -lt 2000 ]; then
fallocate -l 1G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
grep -q /swapfile /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab
fi
# 2. Internal firewall: if iptables ends in REJECT, open 80/443 before it and persist
if iptables -S INPUT | grep -q -- '-j REJECT'; then
pos=$(iptables -L INPUT --line-numbers | awk '/REJECT/{print $1; exit}')
for p in 443 80; do
iptables -C INPUT -p tcp -m state --state NEW --dport "$p" -j ACCEPT 2>/dev/null \
|| iptables -I INPUT "$pos" -p tcp -m state --state NEW --dport "$p" -j ACCEPT
done
command -v netfilter-persistent >/dev/null || apt-get install -y -q iptables-persistent
netfilter-persistent save
fi
# 3. Official ntfy and Caddy repositories
install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://archive.ntfy.sh/apt/keyring.gpg -o /etc/apt/keyrings/ntfy.gpg
echo "deb [arch=$ARCH signed-by=/etc/apt/keyrings/ntfy.gpg] https://archive.ntfy.sh/apt stable main" \
> /etc/apt/sources.list.d/ntfy.list
curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/gpg.key \
| gpg --dearmor --yes -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt \
> /etc/apt/sources.list.d/caddy-stable.list
apt-get update -q
apt-get install -y -q ntfy caddy unattended-upgrades
# 4. ntfy: localhost only, behind Caddy, closed by default
cat > /etc/ntfy/server.yml <<EOF
base-url: "https://${NTFY_DOMAIN}"
listen-http: "127.0.0.1:2586"
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"
upstream-base-url: "https://ntfy.sh"
EOF
install -d -o ntfy -g ntfy /var/cache/ntfy /var/lib/ntfy
# 5. Caddy: HTTPS with Let's Encrypt and proxy to ntfy
cat > /etc/caddy/Caddyfile <<EOF
${NTFY_DOMAIN} {
reverse_proxy 127.0.0.1:2586
}
EOF
systemctl enable --now ntfy caddy
systemctl restart ntfy caddy
sleep 2
# 6. Admin user
ntfy user list 2>&1 | grep -q "user ${NTFY_ADMIN}" || ntfy user add --role=admin "$NTFY_ADMIN"
systemctl is-active ntfy caddy
Run it
The admin password is typed at a prompt (it does not end up in the history) and travels through the environment:
read -rsp 'Admin password for ntfy: ' PW; echo
ssh mi-vm "sudo env NTFY_DOMAIN=ntfy.your-domain.com NTFY_ADMIN=admin NTFY_PASSWORD=$(printf %q "$PW") bash -s" \
< setup-ntfy.sh
unset PW
To generate a random password: openssl rand -base64 18 | tr -d '/+='.
What each part does
| # | Step | Why |
|---|---|---|
| 1 | 1 GB swap | On 1 GB servers, apt can run out of memory |
| 2 | Opens 80/443 before the iptables REJECT | Some images block everything except SSH |
| 3 | Official ntfy and Caddy repos | Up-to-date versions via apt; detects amd64/arm64 |
| 4 | server.yml | See below |
| 5 | Caddyfile | Automatic HTTPS and proxy |
| 6 | Admin user | With deny-all, nobody can publish without a user |
server.yml, line by line
| Option | Value | Why |
|---|---|---|
base-url | https://ntfy.your-domain.com | Correct URLs in the app and attachments |
listen-http | 127.0.0.1:2586 | Local only: public traffic comes in through Caddy |
behind-proxy | true | Takes the real client IP from X-Forwarded-For |
cache-duration | 24h | History the app sees when subscribing |
auth-default-access | deny-all | Nothing without a user or token |
upstream-base-url | https://ntfy.sh | Push relay for iOS |
Caddy
- Obtains and renews the Let’s Encrypt certificate on its own (HTTP-01 challenge on port 80, renewal ~30 days before expiry).
- Handles the app’s long-lived connections (WebSocket and streaming) with no extra configuration.
Verify
curl https://ntfy.your-domain.com/v1/health # {"healthy":true}
curl -s -o /dev/null -w '%{http_code}\n' -d test https://ntfy.your-domain.com/alertas # 403 without credentials
Actual result: {"healthy":true}, a valid certificate and 403 without credentials.
ntfy caddy lets-encrypt iptables ubuntu
Users, permissions and tokens
All commands run on the server with sudo.
Model
auth-default-access: deny-all: without a user or token, nobody can read or publish.- One admin user with full access.
- One token per publishing system (your PC, the server report, a CI…): if one leaks, you revoke only that one.
Users
sudo ntfy user list
sudo ntfy user add --role=admin admin # prompts for the password (or takes it from NTFY_PASSWORD)
sudo ntfy user add lector # regular user
sudo ntfy user change-pass admin
sudo ntfy user del lector
Per-topic permissions (non-admin users)
sudo ntfy access lector alertas read-only # read only
sudo ntfy access ci deploys write-only # publish only
sudo ntfy access lector 'proyecto-*' rw # wildcards
sudo ntfy access # show all
sudo ntfy access --reset lector alertas # remove
Tokens
sudo ntfy token add --label mi-pc admin # prints tk_...
sudo ntfy token add --label ci --expires=90d admin
sudo ntfy token list admin
sudo ntfy token remove admin tk_xxxxxxxx
Tokens used in this guide:
| Token | Where it lives | Purpose |
|---|---|---|
mi-pc | Environment variable on your PC | Alerts from scripts and Claude Code (chapter 09) |
server-report | /etc/ntfy-report.env on the server (root, 600) | Status report (chapter 10) |
Ways to authenticate when publishing
| Method | Example | When |
|---|---|---|
| Token (recommended) | -H "Authorization: Bearer tk_..." | Scripts and servers |
| User and password | -u admin (prompts for the password) | Manual tests; the phone app |
| In the URL | ?auth=<base64> | Only when headers are not possible; it ends up in logs |
Good practices
- Never paste a token into a repo, chat or ticket.
- Revoke and recreate any exposed token.
- Use
--expiresfor temporary tokens.
ntfy auth tokens acl
Set up the phone app
Install
- Android: “ntfy” on Google Play or F-Droid.
- iOS: “ntfy” on the App Store.
1. Add your user for the self-hosted server
⋮ → Settings → Users (General section) → Add user:
| Field | Value |
|---|---|
| Service URL | https://ntfy.your-domain.com |
| Username | admin (or a read-only user) |
| Password | The one you set during installation |
2. Subscribe to the topic
Main screen → +:
| Field | Value |
|---|---|
| Topic | alertas (just the name, no URL or slashes) |
| Use another server | On → https://ntfy.your-domain.com |
| Instant delivery (Android) | On: persistent connection, alerts with no delay |
When you subscribe you will see the messages from the last 24 h (the server cache).
3. Fallback on ntfy.sh
Repeat step 2 with your random ntfy.sh topic and without enabling “Use another server”. If your server goes down, the failure alert arrives through there.
The most common error: a topic with /
Symptom in the subscription list:
ntfy.sh/<random-topic>/Alertas
0 notifications, reconnecting... ⚠
- The full URL plus a name was typed into the Topic field.
- A topic cannot contain
/: the app keeps reconnecting forever and receives nothing. - Fix: delete the subscription and create it with only the topic name.
If notifications do not arrive
| Symptom | Fix |
|---|---|
| ”reconnecting…” with ⚠ | Misspelled topic or server down (curl https://ntfy.your-domain.com/v1/health) |
| They show up in the app but do not ring | Android Settings → Apps → ntfy → Notifications allowed |
| They arrive late with the screen off | Settings → Battery → ntfy → Unrestricted |
| iOS does not receive from the self-hosted server | Check upstream-base-url: "https://ntfy.sh" in server.yml |
| 401/403 when subscribing | User not added for that URL or no read permission on the topic |
Priorities on the phone
min/low: arrives silently (the “OK” report useslow).high: rings and vibrates (the “FAILURE” report useshigh).urgent: rings even on silent.
ntfy android ios troubleshooting
Publish with curl
All examples use $NTFY_TOKEN (a tk_... token) and the topic alertas.
read -rsp 'ntfy token: ' NTFY_TOKEN; echo; export NTFY_TOKEN
1. Simple message
curl -H "Authorization: Bearer $NTFY_TOKEN" -d "Backup finished" https://ntfy.your-domain.com/alertas
2. Title, priority and emoji
curl -H "Authorization: Bearer $NTFY_TOKEN" \
-H "Title: Deploy prod" -H "Priority: high" -H "Tags: rocket" \
-d "Deploy to prod OK" https://ntfy.your-domain.com/alertas
Priority:min,low,default,high,urgent.Tags: emoji names (warning,white_check_mark,rotating_light,rocket,wave).
3. Open a URL when tapped
curl -H "Authorization: Bearer $NTFY_TOKEN" \
-H "Click: https://github.com/<user>/<repo>/actions" \
-d "CI finished" https://ntfy.your-domain.com/alertas
4. User and password instead of a token
curl -u admin -d "Hello" https://ntfy.your-domain.com/alertas # prompts for the password
5. Report a command’s result
make test \
&& curl -H "Authorization: Bearer $NTFY_TOKEN" -H "Tags: white_check_mark" -d "Tests OK" https://ntfy.your-domain.com/alertas \
|| curl -H "Authorization: Bearer $NTFY_TOKEN" -H "Tags: rotating_light" -H "Priority: high" -d "Tests failed" https://ntfy.your-domain.com/alertas
6. Schedule an alert
curl -H "Authorization: Bearer $NTFY_TOKEN" -H "In: 30m" -d "Check the oven" https://ntfy.your-domain.com/alertas
Minimum 10 s, maximum 3 days.
7. Read recent messages
curl -s -H "Authorization: Bearer $NTFY_TOKEN" "https://ntfy.your-domain.com/alertas/json?poll=1&since=1h" | jq -r '.message'
8. Server health (no token)
curl https://ntfy.your-domain.com/v1/health # {"healthy":true}
9. On ntfy.sh (no token)
curl -H "Title: Alert" -d "Hello" https://ntfy.sh/<random-topic>
Response
A successful POST returns the message as JSON:
{"id":"sMMIjkAXy6WU","time":1791754000,"event":"message","topic":"alertas","message":"Backup finished"}
| Code | Meaning |
|---|---|
| 200 | Published |
| 401 / 403 | No token, invalid token or no permission on the topic |
| 429 | Rate limit |
Publishing to a topic nobody is subscribed to still works, but it reaches no one.
ntfy curl cli
Alerts from Claude Code
Why the native notification is not enough
Claude Code ships a push-to-phone tool, but:
- It is skipped if you are active in the terminal (“Not sent — this terminal is active”).
- It only arrives with Remote Control connected and the Claude app granted permissions.
Your own hook with ntfy avoids both conditions.
1. Script: ~/.claude/hooks/ntfy-notify.sh
#!/usr/bin/env bash
# Publishes an alert to your ntfy server and, if configured, to ntfy.sh (fallback).
# Usage: ntfy-notify.sh "message" ["title"] ["tags"]
# or JSON on stdin with a .message field (Claude Code Notification hook).
# Variables: NTFY_URL (https://ntfy.your-domain.com/alertas), NTFY_TOKEN (tk_...), NTFY_TOPIC (ntfy.sh, optional)
set -u
msg="${1:-}"
title="${2:-Claude Code · $(basename "$PWD")}"
tags="${3:-}"
if [ -z "$msg" ] && [ ! -t 0 ]; then
msg=$(jq -r '.message // empty' 2>/dev/null)
fi
msg="${msg:-Claude needs your attention}"
rc=0
if [ -n "${NTFY_URL:-}" ] && [ -n "${NTFY_TOKEN:-}" ]; then
curl -fsS -m 10 -o /dev/null -H "Authorization: Bearer ${NTFY_TOKEN}" \
-H "Title: $title" ${tags:+-H "Tags: $tags"} -d "$msg" "$NTFY_URL" || rc=1
fi
if [ -n "${NTFY_TOPIC:-}" ]; then
curl -fsS -m 10 -o /dev/null -H "Title: $title" ${tags:+-H "Tags: $tags"} \
-d "$msg" "https://ntfy.sh/${NTFY_TOPIC}" || rc=1
fi
exit $rc
2. Variables in ~/.claude/settings.json
{
"env": {
"NTFY_URL": "https://ntfy.your-domain.com/alertas",
"NTFY_TOKEN": "<YOUR_TOKEN>",
"NTFY_TOPIC": "<random-topic>"
}
}
- Without
NTFY_TOPIC, it only uses your server. settings.jsonholds the token in plain text: do not push it to any repo.
3. Notification hook
It fires when Claude Code is waiting for your permission or your answer. Add it to the same settings.json, alongside whatever you already have:
{
"hooks": {
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/ntfy-notify.sh >/dev/null 2>&1 || true",
"async": true
}
]
}
]
}
}
async: true: does not block Claude Code while sending.|| true: if ntfy fails, the hook does not error out.- The script reads the
.messagefield of the JSON the hook receives on stdin.
4. Enable it
- It takes effect in the next session, or in the current one after opening
/hooksonce. - Validate the JSON:
jq -e '.hooks.Notification[0].hooks[0].command' ~/.claude/settings.json.
5. Test it
echo '{"message":"Hook test"}' | NTFY_URL=... NTFY_TOKEN=... bash ~/.claude/hooks/ntfy-notify.sh; echo "exit=$?"
6. Ask Claude for alerts
“Let me know when the CI finishes”: Claude can run the same script when it is done:
bash ~/.claude/hooks/ntfy-notify.sh "CI finished" "Claude Code" "white_check_mark"
claude-code hooks ntfy
Status report every 45 minutes
What it is
Every 45 min the server checks itself and sends the result to your ntfy and to ntfy.sh:
ntfy OK (16:43)
CPU 0% (load 0.00 0.00 0.01) · RAM 409/954 MB (42%) · swap 0 MB · disk 8%
ntfy, caddy and cron active · TLS 89 days · up 45 minutes
- From the server: it does not depend on your PC being on, and it sees internal data.
- Two destinations: if your ntfy stops responding, the alert still arrives through ntfy.sh.
- Limit: if the server dies completely, it sends nothing. Not receiving the report for more than 45 min is the alarm.
What it checks
| Check | How | Fails if |
|---|---|---|
| CPU and load | vmstat 5 2, /proc/loadavg | Informational only |
| RAM, swap, disk | free -m, df | RAM ≥ 90 % or disk ≥ 85 % |
| Services | systemctl is-active ntfy caddy cron | Any of them inactive |
| Full public path | curl https://<domain>/v1/health from the server | Does not answer healthy |
| Certificate | openssl s_client → days until notAfter | Fewer than 10 days (renewal failed) |
| Pending reboot | /var/run/reboot-required | Informational only |
| Result | Title | Priority | Emoji |
|---|---|---|---|
| All good | ntfy OK (hh:mm) | low (silent) | ✅ |
| Something fails | FAILURE ntfy (hh:mm) + list of failures | high | 🚨 |
Script: ntfy-report.sh
#!/usr/bin/env bash
# ntfy server status report. Run by the systemd timer every 45 min.
# Config in /etc/ntfy-report.env (root only):
# NTFY_TOKEN=tk_... NTFY_DOMAIN=ntfy.your-domain.com NTFY_LOCAL_TOPIC=alertas
# NTFY_TOPIC=<random-topic> TZ_REPORT=America/Guayaquil
set -u
. /etc/ntfy-report.env
: "${NTFY_LOCAL_TOPIC:=alertas}" "${TZ_REPORT:=UTC}"
fails=()
cpu=$((100 - $(vmstat 5 2 | tail -1 | awk '{print $15}')))
load=$(cut -d' ' -f1-3 /proc/loadavg)
read -r mem_total mem_used <<<"$(free -m | awk '/^Mem:/{print $2, $3}')"
mem_pct=$((mem_used * 100 / mem_total))
swap_used=$(free -m | awk '/^Swap:/{print $3}')
disk_pct=$(df --output=pcent / | tail -1 | tr -dc '0-9')
[ "$mem_pct" -lt 90 ] || fails+=("RAM ${mem_pct}%")
[ "$disk_pct" -lt 85 ] || fails+=("disk ${disk_pct}%")
for s in ntfy caddy cron; do
systemctl is-active --quiet "$s" || fails+=("$s inactive")
done
# Full public path: DNS + TLS + Caddy + ntfy
health=$(curl -fsS -m 10 "https://${NTFY_DOMAIN}/v1/health" 2>&1)
[[ "$health" == *'"healthy":true'* ]] || fails+=("public health: ${health:0:80}")
# TLS days left
end=$(echo | openssl s_client -servername "$NTFY_DOMAIN" -connect "$NTFY_DOMAIN:443" 2>/dev/null \
| openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
if [ -n "$end" ]; then
tls=$(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 ))
[ "$tls" -ge 10 ] || fails+=("TLS expires in $tls days")
else
tls="?"; fails+=("TLS without certificate")
fi
reboot=""; [ -f /var/run/reboot-required ] && reboot=" · reboot required"
now=$(TZ="$TZ_REPORT" date '+%H:%M')
body="CPU ${cpu}% (load ${load}) · RAM ${mem_used}/${mem_total} MB (${mem_pct}%) · swap ${swap_used} MB · disk ${disk_pct}%
ntfy, caddy and cron active · TLS ${tls} days · up $(uptime -p | sed 's/^up //')${reboot}"
if [ ${#fails[@]} -eq 0 ]; then
title="ntfy OK ($now)"; tags="white_check_mark"; prio="low"
else
title="FAILURE ntfy ($now)"; tags="rotating_light"; prio="high"
body="$(IFS='; '; echo "${fails[*]}")
$body"
fi
post() { curl -fsS -m 10 -o /dev/null -H "Title: $title" -H "Tags: $tags" -H "Priority: $prio" "$@" -d "$body"; }
post -H "Authorization: Bearer ${NTFY_TOKEN}" "http://127.0.0.1:2586/${NTFY_LOCAL_TOPIC}"
[ -z "${NTFY_TOPIC:-}" ] || post "https://ntfy.sh/${NTFY_TOPIC}"
It publishes to your ntfy via
127.0.0.1:2586(bypassing Caddy): that way a “Caddy down” or “TLS expired” alert also arrives through your server.
Installer: install-ntfy-report.sh
#!/usr/bin/env bash
# Installs the report as a systemd timer every 45 min. Runs ON THE SERVER with sudo,
# with ntfy-report.sh copied to /tmp beforehand.
set -e
: "${NTFY_DOMAIN:?NTFY_DOMAIN is missing}" "${NTFY_ADMIN:=admin}"
install -m 0755 /tmp/ntfy-report.sh /usr/local/bin/ntfy-report
# Dedicated token for the report (revocable without affecting others)
if [ ! -f /etc/ntfy-report.env ]; then
TK=$(ntfy token add --label server-report "$NTFY_ADMIN" | grep -oE 'tk_[a-z0-9]+')
umask 077
printf 'NTFY_TOKEN=%s\nNTFY_DOMAIN=%s\nNTFY_LOCAL_TOPIC=%s\nNTFY_TOPIC=%s\nTZ_REPORT=%s\n' \
"$TK" "$NTFY_DOMAIN" "${NTFY_LOCAL_TOPIC:-alertas}" "${NTFY_TOPIC:-}" "${TZ_REPORT:-UTC}" \
> /etc/ntfy-report.env
fi
cat > /etc/systemd/system/ntfy-report.service <<'EOF'
[Unit]
Description=ntfy server status report
After=network-online.target ntfy.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/ntfy-report
EOF
cat > /etc/systemd/system/ntfy-report.timer <<'EOF'
[Unit]
Description=ntfy server status report every 45 minutes
[Timer]
OnBootSec=3min
OnUnitActiveSec=45min
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now ntfy-report.timer
systemctl start ntfy-report.service
systemctl list-timers ntfy-report.timer --no-pager | sed -n 2p
Install from your PC
scp ntfy-report.sh mi-vm:/tmp/
ssh mi-vm "sudo env NTFY_DOMAIN=ntfy.your-domain.com NTFY_ADMIN=admin NTFY_LOCAL_TOPIC=alertas \
NTFY_TOPIC=<random-topic> TZ_REPORT=America/Guayaquil bash -s" < install-ntfy-report.sh
Why a systemd timer and not cron
croncannot express “every 45 minutes”:*/45fires at :00 and :45, leaving 15-minute gaps.OnUnitActiveSec=45mincounts from the last run.OnBootSec=3minsends a report 3 min after every boot, so downtime doesn’t leave a long gap.
Operate
sudo systemctl start ntfy-report.service # report now
systemctl list-timers ntfy-report.timer # next run
journalctl -u ntfy-report -n 20 # latest runs
sudo systemctl disable --now ntfy-report.timer # disable
32 reports per day per destination: below free ntfy.sh’s 250/day limit.
ntfy monitoring systemd-timer healthcheck
Operations
Commands on the server (ssh mi-vm).
Status and logs
systemctl status ntfy caddy --no-pager
journalctl -u ntfy -n 50 --no-pager
journalctl -u caddy -n 50 --no-pager # certificates, proxy errors
curl -s http://127.0.0.1:2586/v1/health # ntfy directly, without Caddy
Change the admin password
sudo ntfy user change-pass admin
Update it in the phone app as well.
Rotate tokens
sudo ntfy token add --label mi-pc admin # new
sudo ntfy token list admin
sudo ntfy token remove admin tk_viejo # revoke
For the report token: edit NTFY_TOKEN in /etc/ntfy-report.env with sudoedit.
Change the configuration
sudoedit /etc/ntfy/server.yml && sudo systemctl restart ntfy
sudoedit /etc/caddy/Caddyfile && sudo systemctl reload caddy
Update
sudo apt-get update && sudo apt-get install --only-upgrade ntfy caddy
unattended-upgrades covers the system’s security patches; ntfy and Caddy come from external repositories and are worth checking now and then. If the report says “reboot required”: sudo reboot (everything starts on its own).
Backup
| File | Contents |
|---|---|
/var/lib/ntfy/user.db | Users and tokens |
/etc/ntfy/server.yml | ntfy configuration |
/etc/caddy/Caddyfile | Proxy and TLS |
/etc/ntfy-report.env | Report token (secret) |
ssh mi-vm 'sudo tar czf - /var/lib/ntfy/user.db /etc/ntfy /etc/caddy/Caddyfile /etc/ntfy-report.env' > ntfy-respaldo-$(date +%F).tgz
The message cache (24 h) does not need a backup.
Move to another server
- Run
setup-ntfy.shon the new one (chapter 05). - Copy
user.db(existing tokens keep working):
ssh mi-vm 'sudo cat /var/lib/ntfy/user.db' | ssh nuevo-vm \
'sudo systemctl stop ntfy && sudo tee /var/lib/ntfy/user.db >/dev/null && sudo chown ntfy:ntfy /var/lib/ntfy/user.db && sudo systemctl start ntfy'
- Point the DNS record to the new IP; Caddy obtains the certificate once DNS points to the new server.
Remove ntfy from the server
sudo systemctl disable --now ntfy caddy ntfy-report.timer
sudo apt-get purge -y ntfy caddy
sudo rm -rf /etc/ntfy /var/lib/ntfy /var/cache/ntfy /etc/caddy /etc/ntfy-report.env \
/usr/local/bin/ntfy-report /etc/systemd/system/ntfy-report.*
And delete the DNS record (chapter 04).
ntfy operations maintenance backup
Problems and solutions
Installation and network
| Problem | Cause | Solution |
|---|---|---|
curl https://... hangs | Port 443 closed in the cloud firewall or in iptables | Open it in both layers (chapter 03) |
| Certificate error | DNS did not point to the IP yet when Caddy started | Wait for propagation; journalctl -u caddy -n 50; sudo systemctl restart caddy |
| 502 Bad Gateway | ntfy down | systemctl status ntfy |
apt dies on a 1 GB server | No swap | The script creates 1 GB of swap |
Publishing and receiving
| Problem | Cause | Solution |
|---|---|---|
| 403 when publishing | auth-default-access: deny-all with no credentials | -H "Authorization: Bearer tk_..." |
| 401 with a token | Token revoked or copied wrong | sudo ntfy token list admin |
| Messages published but not received | Nobody subscribed to that topic | Subscribe to the topic; you’ll only see what was published in the last 24 h |
| App “reconnecting…” with ⚠ | Topic with / (a URL was pasted) | Just the topic name |
| Notifications that do not ring | Permissions or battery optimization | Allow notifications and set battery to “Unrestricted” |
| iOS does not receive | Relay missing | upstream-base-url: "https://ntfy.sh" |
Claude Code
| Problem | Cause | Solution |
|---|---|---|
| The native notification says “Not sent — this terminal is active” | Skipped while you are in the terminal | Your own hook with ntfy (chapter 09) |
| The hook does not fire | Configuration not reloaded | Open /hooks once or start a new session |
settings.json stops loading | Invalid JSON | jq -e . ~/.claude/settings.json |
Status report
| Problem | Cause | Solution |
|---|---|---|
| ”Every 45 min” with cron fires at :00 and :45 | */45 is not a real interval | systemd timer with OnUnitActiveSec=45min |
| A dead server does not alert | It cannot report if it is dead | The absence of a report for more than 45 min is the alarm |
| Too many alerts with sound | All with high priority | ”OK” with low, “FAILURE” with high |
troubleshooting ntfy