Saltar al contenido
← Todos los posts

Tu propio servidor de notificaciones push con ntfy

Guía completa para montar ntfy con dominio propio, HTTPS automático y acceso privado, recibir notificaciones en el celular desde cualquier script con un curl, e integrarlo con Claude Code y con un reporte de estado del servidor.

Por Pablo Contreras28 min de lectura12 capítulos

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

Costo: el de tu VM (puede ser USD 0) · Tiempo: ~45 min · Nivel: intermedio (Linux, SSH, DNS)


TL;DR

  • Qué obtienes: https://ntfy.tu-dominio.com, privado (usuario y token), con TLS de Let’s Encrypt renovado solo.
  • Qué necesitas: una VM Ubuntu 24.04 con IP pública (cualquier proveedor), un dominio y acceso a su DNS.
  • Cómo publicas: curl -H "Authorization: Bearer $TOKEN" -d "Hola" https://ntfy.tu-dominio.com/alertas.
  • Respaldo: ntfy.sh gratis recibe el aviso de falla si tu servidor cae.
  • Monitoreo: el servidor envía cada 45 min su estado (servicios, RAM, disco, días de TLS).

Índice

#CapítuloQué resuelve
01Qué es ntfy y cuándo montar el tuyoArquitectura y alternativas (ntfy.sh gratis, Supporter, servidor propio)
02ntfy.sh gratis y sus límitesCuándo basta la instancia pública
03Requisitos del servidorVM, puertos, firewall del proveedor
04DNSRegistro A sin proxy; Cloudflare por API
05Instalar ntfy + CaddyScript idempotente con TLS automático
06Usuarios, permisos y tokensdeny-all, admin, un token por sistema
07Configurar la app del celularAndroid, iOS y el error del tópico con /
08Publicar con curlTítulo, prioridad, emojis, programados
09Avisos desde Claude CodeHook Notification y script de avisos
10Reporte de estado cada 45 minEl servidor informa su salud
11OperaciónContraseñas, tokens, logs, actualizaciones, borrar
12Problemas y solucionesTodo lo que falló y cómo se resolvió

Convenciones

  • Valores de ejemplo: dominio ntfy.tu-dominio.com, IP 203.0.113.10, usuario admin, tópico alertas, alias SSH mi-vm.
  • Placeholders: <TU_TOKEN>, <tema-aleatorio>. Nunca publiques tokens ni contraseñas.
  • Terminal: Linux o WSL2 con bash, curl y jq.
  • Fecha de los datos: octubre de 2026.

ntfy push-notifications caddy self-hosting claude-code

Qué es ntfy y cuándo montar el tuyo

En una línea

  • Servidor pub/sub por HTTP: publicas con un POST a https://<servidor>/<tópico> y todo dispositivo suscrito a ese tópico recibe una notificación push.
  • Open source (Apache 2.0 / GPLv2), escrito en Go, un solo binario.
  • ntfy.sh es la instancia pública del autor; cualquiera puede instalar la suya.

Cómo llega el mensaje al celular

  1. Script / PC
    (curl POST)
    HTTPS + tokenTu servidor ntfy
    ntfy.tu-dominio.com
  2. Tu servidor ntfy
    ntfy.tu-dominio.com
    conexión persistente
    (entrega instantánea)
    App Android
  3. Tu servidor ntfy
    ntfy.tu-dominio.com
    aviso sin contenidontfy.sh (relay)
  4. ntfy.sh (relay)APNsApp iOS
PiezaCómo funciona
PublicarPOST/PUT a /<tópico>. El tópico se crea solo al primer uso
AndroidLa app mantiene una conexión abierta (WebSocket o stream JSON) con tu servidor
iOSApple solo deja enviar push a quien firmó la app: tu servidor avisa a ntfy.sh (upstream-base-url), que despierta la app vía APNs; la app luego pide el contenido a tu servidor
AlmacenamientoSQLite con caché corta (aquí 24 h); no es una cola persistente

Conceptos

ConceptoQué es
TópicoCanal. Sin autenticación, su nombre funciona como contraseña
UsuarioCuenta con permisos de lectura/escritura por tópico
TokenCredencial tk_... de un usuario, ideal para scripts; revocable por separado
Prioridadmin a urgent; urgent suena aunque el celular esté en silencio
TagsEmojis por nombre (warning, rocket) que aparecen en la notificación

Opciones

OpciónCostoPrivadoMantenimientoCuándo
ntfy.sh gratisUSD 0No: quien adivine el tópico lo leeNingunoAvisos personales sin datos sensibles
ntfy.sh SupporterUSD 5 (periodo: ver cap. 02)Sí (3 tópicos reservados)NingunoPrivacidad sin administrar un servidor
Servidor propioEl de la VMSíMedioControl total, dominio propio, límites propios

Qué implica el servidor propio

Se ganaSe asume
Tópicos privados con usuario y tokenParches, certificados y monitoreo
Sin el límite de 250 mensajes diariosSi el servidor cae, no hay avisos (por eso ntfy.sh queda de respaldo)
Tu dominioUna VM siempre encendida con IP pública

ntfy push-notifications pub-sub alternativas

ntfy.sh gratis y sus límites

Fuentes: docs.ntfy.sh/publish, sección Limitations y ntfy.sh/#pricing (octubre de 2026).


Límites del plan gratis (por IP, sin cuenta)

Límitentfy.sh
Mensajes por día250
Ráfaga60 solicitudes seguidas, luego 1 cada 5 s
Tamaño del mensaje4.096 bytes (título 1 KB, tags 512 bytes)
Adjuntos2 MB cada uno, 20 MB en total, se borran a las 3 h
Emails5 por día
Conexiones de suscripción30
Tráfico200 MB por día
Caché de mensajes~12 h
Tópicos reservadosNinguno

Planes pagos

PlanUSDMensajes/díaTópicos reservadosAdjuntos
Supporter52.500325 MB
Pro1020.00010250 MB
Business2050.000501 GB

La página no aclara si el precio es mensual o anual.

Usarlo bien

  • Tópico con nombre largo y aleatorio (funciona como contraseña):
echo "avisos-$(head -c 12 /dev/urandom | od -An -tx1 | tr -d ' \n')"
  • Nunca envíes datos sensibles: quien conozca el nombre lee todo.

Papel de ntfy.sh con un servidor propio

  • Respaldo: el reporte de estado también publica ahí; si tu servidor cae, el aviso de falla igual llega.
  • Relay de iOS: necesario aunque tengas servidor propio.

ntfy ntfy-sh limites precios

Requisitos del servidor

La máquina

RequisitoMínimoNota
SistemaUbuntu 24.04 (amd64 o arm64)Los comandos usan apt y systemd
RAM1 GBntfy + Caddy usan ~100 MB; agrega swap si tienes 1 GB
CPUCualquieraUna fracción de vCPU sobra
Disco~2 GB libresCaché de mensajes y logs
RedIP pública fijaDebe estar encendida 24/7
AccesoSSH con un usuario con sudoLos ejemplos usan el alias mi-vm

Sirve cualquier proveedor: Hetzner, DigitalOcean, AWS, la capa gratuita de algún proveedor de nube, un servidor en casa con IP fija.

Alias SSH recomendado en ~/.ssh/config:

Host mi-vm
  HostName 203.0.113.10
  User ubuntu
  IdentityFile ~/.ssh/mi_clave

Puertos

PuertoPara qué
22SSH
80Validación de Let’s Encrypt (HTTP-01) y redirección a HTTPS
443ntfy por HTTPS

Ábrelos en todas las capas que tenga tu proveedor:

  1. El firewall de la nube (Security Group, Security List, Firewall de Hetzner…).
  2. El firewall dentro de la VM. Algunas imágenes traen iptables con un REJECT final que bloquea todo salvo SSH; el script de instalación lo resuelve.

Comprobar antes de instalar

ssh mi-vm 'lsb_release -ds; dpkg --print-architecture; free -m | sed -n 2p; sudo iptables -S INPUT'

Si ves -A INPUT -j REJECT al final, el script de instalación abre 80 y 443 antes de esa regla.

Dominio

  • Un subdominio (ntfy.tu-dominio.com) con acceso para crear un registro A (capítulo 04).
  • El registro debe existir antes de instalar Caddy.

ntfy requisitos vps firewall

DNS

El registro

TipoNombreContenidoProxyTTL
Antfy203.0.113.10Sin proxy (“DNS only” en Cloudflare)300

Cualquier proveedor de DNS sirve; solo hace falta el registro A a la IP pública.

Por qué sin proxy

  • Los proxies como el de Cloudflare cortan conexiones HTTP inactivas largas; la app del celular mantiene una abierta todo el tiempo.
  • Caddy valida el dominio con Let’s Encrypt (HTTP-01) directo contra el servidor.
  • Con proxy, ntfy vería la IP del proxy y no la del cliente.

Cloudflare por API

Token con permiso Zone → DNS → Edit sobre la zona. Guárdalo en una variable, nunca en un archivo versionado:

read -rsp 'Token de Cloudflare: ' CF_API_TOKEN; echo
H=(-H "Authorization: Bearer $CF_API_TOKEN" -H "Content-Type: application/json")
DOMINIO=tu-dominio.com; HOST=ntfy.tu-dominio.com; IP=203.0.113.10

# Verificar el token
curl -s "${H[@]}" https://api.cloudflare.com/client/v4/user/tokens/verify | jq -r '.result.status'   # active

# ID de la zona
Z=$(curl -s "${H[@]}" "https://api.cloudflare.com/client/v4/zones?name=$DOMINIO" | jq -r '.result[0].id')

# Crear o actualizar (idempotente)
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}}'

Respuesta esperada:

{"success":true,"r":{"name":"ntfy.tu-dominio.com","content":"203.0.113.10","proxied":false}}

Comprobar

dig +short ntfy.tu-dominio.com     # 203.0.113.10

Borrar

curl -s -X DELETE "${H[@]}" "https://api.cloudflare.com/client/v4/zones/$Z/dns_records/$EX"

dns cloudflare api

Instalar ntfy + Caddy

Antes de empezar

  • Servidor que cumple los requisitos.
  • Registro DNS A creado (capítulo 04): Caddy lo necesita para obtener el certificado.

Script: setup-ntfy.sh

Idempotente: se puede volver a ejecutar sin romper nada.

#!/usr/bin/env bash
# Instala ntfy + Caddy (TLS automático) en Ubuntu 24.04. Se ejecuta EN EL SERVIDOR con sudo.
# Variables: NTFY_DOMAIN (obligatoria), NTFY_ADMIN (default admin), NTFY_PASSWORD (la lee `ntfy user add`).
set -euo pipefail
: "${NTFY_DOMAIN:?falta NTFY_DOMAIN}" "${NTFY_ADMIN:=admin}"
export DEBIAN_FRONTEND=noninteractive
ARCH=$(dpkg --print-architecture)          # amd64 o arm64

# 1. Swap de 1 GB si no hay (servidores de 1 GB de 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. Firewall interno: si iptables termina en REJECT, abrir 80/443 antes y persistir
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. Repositorios oficiales de ntfy y Caddy
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: solo en localhost, detrás de Caddy, cerrado por defecto
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 con Let's Encrypt y proxy a 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. Usuario administrador
ntfy user list 2>&1 | grep -q "user ${NTFY_ADMIN}" || ntfy user add --role=admin "$NTFY_ADMIN"

systemctl is-active ntfy caddy

Ejecutar

La clave del admin se pide por teclado (no queda en el historial) y viaja por el entorno:

read -rsp 'Clave del admin de ntfy: ' PW; echo
ssh mi-vm "sudo env NTFY_DOMAIN=ntfy.tu-dominio.com NTFY_ADMIN=admin NTFY_PASSWORD=$(printf %q "$PW") bash -s" \
  < setup-ntfy.sh
unset PW

Para generar una clave aleatoria: openssl rand -base64 18 | tr -d '/+='.

Qué hace cada parte

#PasoPor qué
1Swap de 1 GBEn servidores de 1 GB, apt puede quedarse sin memoria
2Abre 80/443 antes del REJECT de iptablesAlgunas imágenes bloquean todo salvo SSH
3Repos oficiales de ntfy y CaddyVersiones al día con apt; detecta amd64/arm64
4server.ymlVer abajo
5CaddyfileHTTPS automático y proxy
6Usuario adminCon deny-all, sin usuario nadie publica

server.yml, línea por línea

OpciónValorPor qué
base-urlhttps://ntfy.tu-dominio.comURLs correctas en la app y los adjuntos
listen-http127.0.0.1:2586Solo local: el tráfico público entra por Caddy
behind-proxytrueToma la IP real del cliente desde X-Forwarded-For
cache-duration24hHistorial que ve la app al suscribirse
auth-default-accessdeny-allNada sin usuario o token
upstream-base-urlhttps://ntfy.shRelay de push para iOS

Caddy

  • Pide y renueva el certificado de Let’s Encrypt solo (desafío HTTP-01 por el puerto 80, renovación ~30 días antes de vencer).
  • Soporta las conexiones largas (WebSocket y streaming) de la app sin configuración extra.

Verificar

curl https://ntfy.tu-dominio.com/v1/health                                          # {"healthy":true}
curl -s -o /dev/null -w '%{http_code}\n' -d test https://ntfy.tu-dominio.com/alertas  # 403 sin credenciales

Resultado real: {"healthy":true}, certificado válido y 403 sin credenciales.

ntfy caddy lets-encrypt iptables ubuntu

Usuarios, permisos y tokens

Todos los comandos se ejecutan en el servidor con sudo.


Modelo

  • auth-default-access: deny-all: sin usuario o token nadie lee ni publica.
  • Un usuario admin con acceso total.
  • Un token por sistema que publica (tu PC, el reporte del servidor, un CI…): si uno se filtra, revocas solo ese.

Usuarios

sudo ntfy user list
sudo ntfy user add --role=admin admin        # pide la clave (o la toma de NTFY_PASSWORD)
sudo ntfy user add lector                    # usuario normal
sudo ntfy user change-pass admin
sudo ntfy user del lector

Permisos por tópico (usuarios no admin)

sudo ntfy access lector alertas read-only    # solo leer
sudo ntfy access ci deploys write-only       # solo publicar
sudo ntfy access lector 'proyecto-*' rw      # comodines
sudo ntfy access                             # ver todos
sudo ntfy access --reset lector alertas      # quitar

Tokens

sudo ntfy token add --label mi-pc admin            # imprime tk_...
sudo ntfy token add --label ci --expires=90d admin
sudo ntfy token list admin
sudo ntfy token remove admin tk_xxxxxxxx

Tokens que usa esta guía:

TokenDónde vivePara qué
mi-pcVariable de entorno de tu PCAvisos de scripts y de Claude Code (capítulo 09)
server-report/etc/ntfy-report.env en el servidor (root, 600)Reporte de estado (capítulo 10)

Formas de autenticarse al publicar

FormaEjemploCuándo
Token (recomendada)-H "Authorization: Bearer tk_..."Scripts y servidores
Usuario y clave-u admin (pide la clave)Pruebas manuales; la app del celular
En la URL?auth=<base64>Solo si no hay headers; queda en logs

Buenas prácticas

  • Nunca pegues un token en un repo, chat o ticket.
  • Revoca y recrea cualquier token expuesto.
  • Usa --expires para tokens temporales.

ntfy auth tokens acl

Configurar la app del celular

Instalar

  • Android: “ntfy” en Google Play o F-Droid.
  • iOS: “ntfy” en App Store.

1. Agregar tu usuario para el servidor propio

⋮ → Configuración → Usuarios (sección General) → Agregar usuario:

CampoValor
URL del serviciohttps://ntfy.tu-dominio.com
Usuarioadmin (o un usuario de solo lectura)
ContraseñaLa que definiste al instalar

2. Suscribirte al tópico

Pantalla principal → +:

CampoValor
Tópicoalertas (solo el nombre, sin URL ni barras)
Usar otro servidorActivado → https://ntfy.tu-dominio.com
Entrega instantánea (Android)Activada: conexión permanente, avisos sin demora

Al suscribirte verás los mensajes de las últimas 24 h (la caché del servidor).

3. Respaldo en ntfy.sh

Repite el paso 2 con tu tópico aleatorio de ntfy.sh y sin activar “Usar otro servidor”. Si tu servidor cae, el aviso de falla llega por ahí.

El error más común: tópico con /

Síntoma en la lista de suscripciones:

ntfy.sh/<tema-aleatorio>/Alertas
0 notificaciones, reconectando...  ⚠
  • Se escribió en el campo Tópico la URL completa más un nombre.
  • Un tópico no puede tener /: la app queda reconectando para siempre y no recibe nada.
  • Solución: borrar la suscripción y crearla con solo el nombre del tópico.

Si no llegan notificaciones

SíntomaSolución
”reconectando…” con ⚠Tópico mal escrito o servidor caído (curl https://ntfy.tu-dominio.com/v1/health)
Aparecen en la app pero no suenanAjustes de Android → Apps → ntfy → Notificaciones permitidas
Llegan tarde con la pantalla apagadaAjustes → Batería → ntfy → Sin restricciones
iOS no recibe del servidor propioRevisa upstream-base-url: "https://ntfy.sh" en server.yml
401/403 al suscribirteUsuario no agregado para esa URL o sin permiso de lectura en el tópico

Prioridades en el celular

  • min/low: llega sin sonido (el reporte “OK” usa low).
  • high: suena y vibra (el reporte “FALLO” usa high).
  • urgent: suena aunque esté en silencio.

ntfy android ios troubleshooting

Publicar con curl

Todos los ejemplos usan $NTFY_TOKEN (un token tk_...) y el tópico alertas.

read -rsp 'Token de ntfy: ' NTFY_TOKEN; echo; export NTFY_TOKEN

1. Mensaje simple

curl -H "Authorization: Bearer $NTFY_TOKEN" -d "Backup terminado" https://ntfy.tu-dominio.com/alertas

2. Título, prioridad y emoji

curl -H "Authorization: Bearer $NTFY_TOKEN" \
     -H "Title: Deploy prod" -H "Priority: high" -H "Tags: rocket" \
     -d "Deploy a prod OK" https://ntfy.tu-dominio.com/alertas
  • Priority: min, low, default, high, urgent.
  • Tags: nombres de emoji (warning, white_check_mark, rotating_light, rocket, wave).

3. Abrir una URL al tocarla

curl -H "Authorization: Bearer $NTFY_TOKEN" \
     -H "Click: https://github.com/<usuario>/<repo>/actions" \
     -d "CI terminó" https://ntfy.tu-dominio.com/alertas

4. Usuario y contraseña en vez de token

curl -u admin -d "Hola" https://ntfy.tu-dominio.com/alertas   # pide la clave

5. Avisar el resultado de un comando

make test \
  && curl -H "Authorization: Bearer $NTFY_TOKEN" -H "Tags: white_check_mark" -d "Tests OK" https://ntfy.tu-dominio.com/alertas \
  || curl -H "Authorization: Bearer $NTFY_TOKEN" -H "Tags: rotating_light" -H "Priority: high" -d "Tests fallaron" https://ntfy.tu-dominio.com/alertas

6. Programar un aviso

curl -H "Authorization: Bearer $NTFY_TOKEN" -H "In: 30m" -d "Revisa el horno" https://ntfy.tu-dominio.com/alertas

Mínimo 10 s, máximo 3 días.

7. Leer los mensajes recientes

curl -s -H "Authorization: Bearer $NTFY_TOKEN" "https://ntfy.tu-dominio.com/alertas/json?poll=1&since=1h" | jq -r '.message'

8. Salud del servidor (sin token)

curl https://ntfy.tu-dominio.com/v1/health      # {"healthy":true}

9. En ntfy.sh (sin token)

curl -H "Title: Aviso" -d "Hola" https://ntfy.sh/<tema-aleatorio>

Respuesta

Un POST correcto devuelve el mensaje en JSON:

{"id":"sMMIjkAXy6WU","time":1791754000,"event":"message","topic":"alertas","message":"Backup terminado"}
CódigoSignificado
200Publicado
401 / 403Sin token, token inválido o sin permiso en el tópico
429Límite de tasa

Publicar en un tópico al que nadie está suscrito funciona igual, pero no le llega a nadie.

ntfy curl cli

Avisos desde Claude Code

Por qué no basta la notificación nativa

Claude Code trae una herramienta de push al celular, pero:

  • Se omite si estás activo en la terminal (“Not sent — this terminal is active”).
  • Solo llega con Remote Control conectado y la app de Claude con permisos.

Un hook propio con ntfy evita las dos condiciones.

1. Script: ~/.claude/hooks/ntfy-notify.sh

#!/usr/bin/env bash
# Publica un aviso en tu servidor ntfy y, si está configurado, en ntfy.sh (respaldo).
# Uso: ntfy-notify.sh "mensaje" ["título"] ["tags"]
#      o JSON por stdin con campo .message (hook Notification de Claude Code).
# Variables: NTFY_URL (https://ntfy.tu-dominio.com/alertas), NTFY_TOKEN (tk_...), NTFY_TOPIC (ntfy.sh, opcional)
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 necesita tu atención}"

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 en ~/.claude/settings.json

{
  "env": {
    "NTFY_URL": "https://ntfy.tu-dominio.com/alertas",
    "NTFY_TOKEN": "<TU_TOKEN>",
    "NTFY_TOPIC": "<tema-aleatorio>"
  }
}
  • Sin NTFY_TOPIC, solo usa tu servidor.
  • settings.json queda con el token en texto plano: no lo subas a ningún repo.

3. Hook Notification

Se dispara cuando Claude Code espera tu permiso o tu respuesta. Agrégalo al mismo settings.json, junto a lo que ya tengas:

{
  "hooks": {
    "Notification": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash ~/.claude/hooks/ntfy-notify.sh >/dev/null 2>&1 || true",
            "async": true
          }
        ]
      }
    ]
  }
}
  • async: true: no frena a Claude Code mientras envía.
  • || true: si ntfy falla, el hook no da error.
  • El script lee el campo .message del JSON que recibe el hook por stdin.

4. Activarlo

  • Toma efecto en la próxima sesión, o en la actual después de abrir /hooks una vez.
  • Valida el JSON: jq -e '.hooks.Notification[0].hooks[0].command' ~/.claude/settings.json.

5. Probarlo

echo '{"message":"Prueba del hook"}' | NTFY_URL=... NTFY_TOKEN=... bash ~/.claude/hooks/ntfy-notify.sh; echo "exit=$?"

6. Pedirle avisos a Claude

“Avísame cuando termine el CI”: Claude puede ejecutar el mismo script al terminar:

bash ~/.claude/hooks/ntfy-notify.sh "CI terminado" "Claude Code" "white_check_mark"

claude-code hooks ntfy

Reporte de estado cada 45 minutos

Qué es

El servidor se revisa a sí mismo cada 45 min y envía el resultado a tu ntfy y a ntfy.sh:

ntfy OK (16:43)
CPU 0% (load 0.00 0.00 0.01) · RAM 409/954 MB (42%) · swap 0 MB · disco 8%
ntfy, caddy y cron activos · TLS 89 días · encendido 45 minutes
  • Desde el servidor: no depende de que tu PC esté encendida y ve datos internos.
  • Doble destino: si tu ntfy deja de responder, el aviso llega igual por ntfy.sh.
  • Límite: si el servidor muere por completo, no envía nada. No recibir el reporte por más de 45 min es la alarma.

Qué revisa

RevisiónCómoFalla si
CPU y cargavmstat 5 2, /proc/loadavgSolo informativo
RAM, swap, discofree -m, dfRAM ≥ 90 % o disco ≥ 85 %
Serviciossystemctl is-active ntfy caddy cronAlguno inactivo
Ruta pública completacurl https://<dominio>/v1/health desde el servidorNo responde healthy
Certificadoopenssl s_client → días a notAfterMenos de 10 días (la renovación falló)
Reinicio pendiente/var/run/reboot-requiredSolo informativo
ResultadoTítuloPrioridadEmoji
Todo bienntfy OK (hh:mm)low (sin sonido)✅
Algo fallaFALLO ntfy (hh:mm) + lista de fallashigh🚨

Script: ntfy-report.sh

#!/usr/bin/env bash
# Reporte de estado del servidor ntfy. Lo ejecuta el timer systemd cada 45 min.
# Config en /etc/ntfy-report.env (solo root):
#   NTFY_TOKEN=tk_...  NTFY_DOMAIN=ntfy.tu-dominio.com  NTFY_LOCAL_TOPIC=alertas
#   NTFY_TOPIC=<tema-aleatorio>  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+=("disco ${disk_pct}%")

for s in ntfy caddy cron; do
  systemctl is-active --quiet "$s" || fails+=("$s inactivo")
done

# Ruta pública completa: DNS + TLS + Caddy + ntfy
health=$(curl -fsS -m 10 "https://${NTFY_DOMAIN}/v1/health" 2>&1)
[[ "$health" == *'"healthy":true'* ]] || fails+=("health público: ${health:0:80}")

# Días de TLS
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 vence en $tls días")
else
  tls="?"; fails+=("TLS sin certificado")
fi

reboot=""; [ -f /var/run/reboot-required ] && reboot=" · requiere reinicio"
now=$(TZ="$TZ_REPORT" date '+%H:%M')
body="CPU ${cpu}% (load ${load}) · RAM ${mem_used}/${mem_total} MB (${mem_pct}%) · swap ${swap_used} MB · disco ${disk_pct}%
ntfy, caddy y cron activos · TLS ${tls} días · encendido $(uptime -p | sed 's/^up //')${reboot}"

if [ ${#fails[@]} -eq 0 ]; then
  title="ntfy OK ($now)"; tags="white_check_mark"; prio="low"
else
  title="FALLO 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}"

Publica a tu ntfy por 127.0.0.1:2586 (sin pasar por Caddy): así el aviso de “Caddy caído” o “TLS vencido” también llega por tu servidor.

Instalador: install-ntfy-report.sh

#!/usr/bin/env bash
# Instala el reporte como timer systemd cada 45 min. Se ejecuta EN EL SERVIDOR con sudo,
# con ntfy-report.sh copiado antes a /tmp.
set -e
: "${NTFY_DOMAIN:?falta NTFY_DOMAIN}" "${NTFY_ADMIN:=admin}"
install -m 0755 /tmp/ntfy-report.sh /usr/local/bin/ntfy-report

# Token propio para el reporte (revocable sin afectar a otros)
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=Reporte de estado del servidor ntfy
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=Reporte de estado del servidor ntfy cada 45 minutos

[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

Instalar desde tu PC

scp ntfy-report.sh mi-vm:/tmp/
ssh mi-vm "sudo env NTFY_DOMAIN=ntfy.tu-dominio.com NTFY_ADMIN=admin NTFY_LOCAL_TOPIC=alertas \
  NTFY_TOPIC=<tema-aleatorio> TZ_REPORT=America/Guayaquil bash -s" < install-ntfy-report.sh

Por qué un timer de systemd y no cron

  • cron no puede expresar “cada 45 minutos”: */45 dispara a :00 y :45, con huecos de 15 min.
  • OnUnitActiveSec=45min cuenta desde la última ejecución.
  • OnBootSec=3min envía un reporte 3 min después de cada arranque, así que un apagado no deja un hueco largo.

Operar

sudo systemctl start ntfy-report.service         # reporte ahora
systemctl list-timers ntfy-report.timer          # próxima ejecución
journalctl -u ntfy-report -n 20                  # últimas ejecuciones
sudo systemctl disable --now ntfy-report.timer   # desactivar

32 reportes al día por destino: por debajo del límite de 250/día de ntfy.sh gratis.

ntfy monitoring systemd-timer healthcheck

Operación

Comandos en el servidor (ssh mi-vm).


Estado y logs

systemctl status ntfy caddy --no-pager
journalctl -u ntfy -n 50 --no-pager
journalctl -u caddy -n 50 --no-pager       # certificados, errores de proxy
curl -s http://127.0.0.1:2586/v1/health     # ntfy directo, sin Caddy

Cambiar la contraseña del admin

sudo ntfy user change-pass admin

Actualízala también en la app del celular.

Rotar tokens

sudo ntfy token add --label mi-pc admin        # nuevo
sudo ntfy token list admin
sudo ntfy token remove admin tk_viejo          # revocar

Para el token del reporte: edita NTFY_TOKEN en /etc/ntfy-report.env con sudoedit.

Cambiar configuración

sudoedit /etc/ntfy/server.yml && sudo systemctl restart ntfy
sudoedit /etc/caddy/Caddyfile && sudo systemctl reload caddy

Actualizar

sudo apt-get update && sudo apt-get install --only-upgrade ntfy caddy

unattended-upgrades cubre los parches de seguridad del sistema; ntfy y Caddy vienen de repositorios externos y conviene revisarlos de vez en cuando. Si el reporte dice “requiere reinicio”: sudo reboot (todo arranca solo).

Respaldo

ArchivoContenido
/var/lib/ntfy/user.dbUsuarios y tokens
/etc/ntfy/server.ymlConfiguración de ntfy
/etc/caddy/CaddyfileProxy y TLS
/etc/ntfy-report.envToken del reporte (secreto)
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

La caché de mensajes (24 h) no necesita respaldo.

Mudar a otro servidor

  1. Ejecuta setup-ntfy.sh en el nuevo (capítulo 05).
  2. Copia user.db (los tokens existentes siguen sirviendo):
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. Cambia el registro DNS a la nueva IP; Caddy obtiene el certificado cuando el DNS ya apunta al servidor nuevo.

Borrar ntfy del servidor

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.*

Y borra el registro DNS (capítulo 04).

ntfy operacion mantenimiento backup

Problemas y soluciones

Instalación y red

ProblemaCausaSolución
curl https://... se queda colgadoPuerto 443 cerrado en el firewall de la nube o en iptablesAbrir en ambas capas (capítulo 03)
Error de certificadoEl DNS aún no apunta a la IP cuando arrancó CaddyEsperar la propagación; journalctl -u caddy -n 50; sudo systemctl restart caddy
502 Bad Gatewayntfy caídosystemctl status ntfy
apt muere en un servidor de 1 GBSin swapEl script crea 1 GB de swap

Publicar y recibir

ProblemaCausaSolución
403 al publicarauth-default-access: deny-all sin credenciales-H "Authorization: Bearer tk_..."
401 con tokenToken revocado o mal copiadosudo ntfy token list admin
Mensajes publicados pero no recibidosNadie suscrito a ese tópicoSuscribirse al tópico; al hacerlo solo verás lo publicado en las últimas 24 h
App “reconectando…” con ⚠Tópico con / (se pegó una URL)Solo el nombre del tópico
Notificaciones que no suenanPermisos u optimización de bateríaPermitir notificaciones y “Sin restricciones” de batería
iOS no recibeFalta el relayupstream-base-url: "https://ntfy.sh"

Claude Code

ProblemaCausaSolución
La notificación nativa dice “Not sent — this terminal is active”Se omite si estás en la terminalHook propio con ntfy (capítulo 09)
El hook no se disparaConfiguración no recargadaAbrir /hooks una vez o iniciar otra sesión
settings.json deja de cargarJSON inválidojq -e . ~/.claude/settings.json

Reporte de estado

ProblemaCausaSolución
”Cada 45 min” con cron dispara a :00 y :45*/45 no es un intervalo realTimer de systemd con OnUnitActiveSec=45min
El servidor caído no avisaNo puede reportar si está muertoLa ausencia de reporte por más de 45 min es la alarma
Demasiados avisos con sonidoTodos con prioridad alta”OK” con low, “FALLO” con high

troubleshooting ntfy

↑ Volver arriba