Skip to content
← All posts

Your own push notification server with ntfy

A complete guide to running ntfy on your own domain with automatic HTTPS and private access, receiving notifications on your phone from any script with a single curl, and integrating it with Claude Code and a server status report.

By Pablo Contreras27 min read12 chapters

  • ntfy
  • push-notifications
  • caddy
  • self-hosting
  • claude-code

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

#ChapterWhat it covers
01What ntfy is and when to run your ownArchitecture and alternatives (free ntfy.sh, Supporter, self-hosted server)
02Free ntfy.sh and its limitsWhen the public instance is enough
03Server requirementsVM, ports, provider firewall
04DNSUnproxied A record; Cloudflare via API
05Install ntfy + CaddyIdempotent script with automatic TLS
06Users, permissions and tokensdeny-all, admin, one token per system
07Set up the phone appAndroid, iOS and the topic-with-/ error
08Publish with curlTitle, priority, emojis, scheduled messages
09Alerts from Claude CodeNotification hook and alert script
10Status report every 45 minThe server reports its own health
11OperationsPasswords, tokens, logs, updates, removal
12Problems and solutionsEverything that failed and how it was fixed

Conventions

  • Example values: domain ntfy.your-domain.com, IP 203.0.113.10, user admin, topic alertas, SSH alias mi-vm.
  • Placeholders: <YOUR_TOKEN>, <random-topic>. Never publish tokens or passwords.
  • Terminal: Linux or WSL2 with bash, curl and jq.
  • 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 POST to https://<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

  1. Script / PC
    (curl POST)
    HTTPS + tokenYour ntfy server
    ntfy.your-domain.com
  2. Your ntfy server
    ntfy.your-domain.com
    persistent connection
    (instant delivery)
    Android app
  3. Your ntfy server
    ntfy.your-domain.com
    content-free pokentfy.sh (relay)
  4. ntfy.sh (relay)APNsiOS app
PieceHow it works
PublishPOST/PUT to /<topic>. The topic is created on first use
AndroidThe app keeps an open connection (WebSocket or JSON stream) to your server
iOSApple 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
StorageSQLite with a short cache (24 h here); it is not a persistent queue

Concepts

ConceptWhat it is
TopicA channel. Without authentication, its name acts as the password
UserAn account with read/write permissions per topic
TokenA user’s tk_... credential, ideal for scripts; revocable independently
Prioritymin to urgent; urgent rings even when the phone is on silent
TagsEmojis by name (warning, rocket) shown in the notification

Options

OptionCostPrivateMaintenanceWhen
Free ntfy.shUSD 0No: anyone who guesses the topic can read itNonePersonal alerts with no sensitive data
ntfy.sh SupporterUSD 5 (billing period: see ch. 02)Yes (3 reserved topics)NonePrivacy without running a server
Self-hosted serverThe VM’s costYesMediumFull control, your own domain, your own limits

What running your own server means

You gainYou take on
Private topics with user and tokenPatches, certificates and monitoring
No 250-messages-per-day limitIf the server goes down, no alerts (that is why ntfy.sh stays as a fallback)
Your domainA 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)

Limitntfy.sh
Messages per day250
Burst60 requests in a row, then 1 every 5 s
Message size4,096 bytes (title 1 KB, tags 512 bytes)
Attachments2 MB each, 20 MB total, deleted after 3 h
Emails5 per day
Subscription connections30
Traffic200 MB per day
Message cache~12 h
Reserved topicsNone

Paid plans

PlanUSDMessages/dayReserved topicsAttachments
Supporter52,500325 MB
Pro1020,00010250 MB
Business2050,000501 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

RequirementMinimumNote
OSUbuntu 24.04 (amd64 or arm64)The commands use apt and systemd
RAM1 GBntfy + Caddy use ~100 MB; add swap if you have 1 GB
CPUAnyA fraction of a vCPU is plenty
Disk~2 GB freeMessage cache and logs
NetworkStatic public IPMust be on 24/7
AccessSSH with a sudo userThe 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

PortPurpose
22SSH
80Let’s Encrypt validation (HTTP-01) and redirect to HTTPS
443ntfy over HTTPS

Open them in every layer your provider has:

  1. The cloud firewall (Security Group, Security List, Hetzner Firewall…).
  2. The firewall inside the VM. Some images ship iptables with a final REJECT that 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 an A record (chapter 04).
  • The record must exist before installing Caddy.

ntfy requirements vps firewall

DNS

The record

TypeNameContentProxyTTL
Antfy203.0.113.10No 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 A record 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

#StepWhy
11 GB swapOn 1 GB servers, apt can run out of memory
2Opens 80/443 before the iptables REJECTSome images block everything except SSH
3Official ntfy and Caddy reposUp-to-date versions via apt; detects amd64/arm64
4server.ymlSee below
5CaddyfileAutomatic HTTPS and proxy
6Admin userWith deny-all, nobody can publish without a user

server.yml, line by line

OptionValueWhy
base-urlhttps://ntfy.your-domain.comCorrect URLs in the app and attachments
listen-http127.0.0.1:2586Local only: public traffic comes in through Caddy
behind-proxytrueTakes the real client IP from X-Forwarded-For
cache-duration24hHistory the app sees when subscribing
auth-default-accessdeny-allNothing without a user or token
upstream-base-urlhttps://ntfy.shPush 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:

TokenWhere it livesPurpose
mi-pcEnvironment variable on your PCAlerts 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

MethodExampleWhen
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 --expires for 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:

FieldValue
Service URLhttps://ntfy.your-domain.com
Usernameadmin (or a read-only user)
PasswordThe one you set during installation

2. Subscribe to the topic

Main screen → +:

FieldValue
Topicalertas (just the name, no URL or slashes)
Use another serverOn → 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

SymptomFix
”reconnecting…” with ⚠Misspelled topic or server down (curl https://ntfy.your-domain.com/v1/health)
They show up in the app but do not ringAndroid Settings → Apps → ntfy → Notifications allowed
They arrive late with the screen offSettings → Battery → ntfy → Unrestricted
iOS does not receive from the self-hosted serverCheck upstream-base-url: "https://ntfy.sh" in server.yml
401/403 when subscribingUser not added for that URL or no read permission on the topic

Priorities on the phone

  • min/low: arrives silently (the “OK” report uses low).
  • high: rings and vibrates (the “FAILURE” report uses high).
  • 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"}
CodeMeaning
200Published
401 / 403No token, invalid token or no permission on the topic
429Rate 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.json holds 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 .message field 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 /hooks once.
  • 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

CheckHowFails if
CPU and loadvmstat 5 2, /proc/loadavgInformational only
RAM, swap, diskfree -m, dfRAM ≥ 90 % or disk ≥ 85 %
Servicessystemctl is-active ntfy caddy cronAny of them inactive
Full public pathcurl https://<domain>/v1/health from the serverDoes not answer healthy
Certificateopenssl s_client → days until notAfterFewer than 10 days (renewal failed)
Pending reboot/var/run/reboot-requiredInformational only
ResultTitlePriorityEmoji
All goodntfy OK (hh:mm)low (silent)✅
Something failsFAILURE ntfy (hh:mm) + list of failureshigh🚨

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

  • cron cannot express “every 45 minutes”: */45 fires at :00 and :45, leaving 15-minute gaps.
  • OnUnitActiveSec=45min counts from the last run.
  • OnBootSec=3min sends 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

FileContents
/var/lib/ntfy/user.dbUsers and tokens
/etc/ntfy/server.ymlntfy configuration
/etc/caddy/CaddyfileProxy and TLS
/etc/ntfy-report.envReport 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

  1. Run setup-ntfy.sh on the new one (chapter 05).
  2. 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'
  1. 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

ProblemCauseSolution
curl https://... hangsPort 443 closed in the cloud firewall or in iptablesOpen it in both layers (chapter 03)
Certificate errorDNS did not point to the IP yet when Caddy startedWait for propagation; journalctl -u caddy -n 50; sudo systemctl restart caddy
502 Bad Gatewayntfy downsystemctl status ntfy
apt dies on a 1 GB serverNo swapThe script creates 1 GB of swap

Publishing and receiving

ProblemCauseSolution
403 when publishingauth-default-access: deny-all with no credentials-H "Authorization: Bearer tk_..."
401 with a tokenToken revoked or copied wrongsudo ntfy token list admin
Messages published but not receivedNobody subscribed to that topicSubscribe 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 ringPermissions or battery optimizationAllow notifications and set battery to “Unrestricted”
iOS does not receiveRelay missingupstream-base-url: "https://ntfy.sh"

Claude Code

ProblemCauseSolution
The native notification says “Not sent — this terminal is active”Skipped while you are in the terminalYour own hook with ntfy (chapter 09)
The hook does not fireConfiguration not reloadedOpen /hooks once or start a new session
settings.json stops loadingInvalid JSONjq -e . ~/.claude/settings.json

Status report

ProblemCauseSolution
”Every 45 min” with cron fires at :00 and :45*/45 is not a real intervalsystemd timer with OnUnitActiveSec=45min
A dead server does not alertIt cannot report if it is deadThe absence of a report for more than 45 min is the alarm
Too many alerts with soundAll with high priority”OK” with low, “FAILURE” with high

troubleshooting ntfy

↑ Back to top