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ítulo | Qué resuelve |
|---|---|---|
| 01 | Qué es ntfy y cuándo montar el tuyo | Arquitectura y alternativas (ntfy.sh gratis, Supporter, servidor propio) |
| 02 | ntfy.sh gratis y sus límites | Cuándo basta la instancia pública |
| 03 | Requisitos del servidor | VM, puertos, firewall del proveedor |
| 04 | DNS | Registro A sin proxy; Cloudflare por API |
| 05 | Instalar ntfy + Caddy | Script idempotente con TLS automático |
| 06 | Usuarios, permisos y tokens | deny-all, admin, un token por sistema |
| 07 | Configurar la app del celular | Android, iOS y el error del tópico con / |
| 08 | Publicar con curl | Título, prioridad, emojis, programados |
| 09 | Avisos desde Claude Code | Hook Notification y script de avisos |
| 10 | Reporte de estado cada 45 min | El servidor informa su salud |
| 11 | Operación | Contraseñas, tokens, logs, actualizaciones, borrar |
| 12 | Problemas y soluciones | Todo lo que falló y cómo se resolvió |
Convenciones
- Valores de ejemplo: dominio
ntfy.tu-dominio.com, IP203.0.113.10, usuarioadmin, tópicoalertas, alias SSHmi-vm. - Placeholders:
<TU_TOKEN>,<tema-aleatorio>. Nunca publiques tokens ni contraseñas. - Terminal: Linux o WSL2 con
bash,curlyjq. - 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
POSTahttps://<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
- Script / PC
(curl POST)HTTPS + tokenTu servidor ntfy
ntfy.tu-dominio.com - Tu servidor ntfy
ntfy.tu-dominio.comconexión persistente
(entrega instantánea)App Android - Tu servidor ntfy
ntfy.tu-dominio.comaviso sin contenidontfy.sh (relay) - ntfy.sh (relay)APNsApp iOS
| Pieza | Cómo funciona |
|---|---|
| Publicar | POST/PUT a /<tópico>. El tópico se crea solo al primer uso |
| Android | La app mantiene una conexión abierta (WebSocket o stream JSON) con tu servidor |
| iOS | Apple 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 |
| Almacenamiento | SQLite con caché corta (aquí 24 h); no es una cola persistente |
Conceptos
| Concepto | Qué es |
|---|---|
| Tópico | Canal. Sin autenticación, su nombre funciona como contraseña |
| Usuario | Cuenta con permisos de lectura/escritura por tópico |
| Token | Credencial tk_... de un usuario, ideal para scripts; revocable por separado |
| Prioridad | min a urgent; urgent suena aunque el celular esté en silencio |
| Tags | Emojis por nombre (warning, rocket) que aparecen en la notificación |
Opciones
| Opción | Costo | Privado | Mantenimiento | Cuándo |
|---|---|---|---|---|
| ntfy.sh gratis | USD 0 | No: quien adivine el tópico lo lee | Ninguno | Avisos personales sin datos sensibles |
| ntfy.sh Supporter | USD 5 (periodo: ver cap. 02) | Sí (3 tópicos reservados) | Ninguno | Privacidad sin administrar un servidor |
| Servidor propio | El de la VM | Sí | Medio | Control total, dominio propio, límites propios |
Qué implica el servidor propio
| Se gana | Se asume |
|---|---|
| Tópicos privados con usuario y token | Parches, certificados y monitoreo |
| Sin el límite de 250 mensajes diarios | Si el servidor cae, no hay avisos (por eso ntfy.sh queda de respaldo) |
| Tu dominio | Una 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ímite | ntfy.sh |
|---|---|
| Mensajes por día | 250 |
| Ráfaga | 60 solicitudes seguidas, luego 1 cada 5 s |
| Tamaño del mensaje | 4.096 bytes (título 1 KB, tags 512 bytes) |
| Adjuntos | 2 MB cada uno, 20 MB en total, se borran a las 3 h |
| Emails | 5 por día |
| Conexiones de suscripción | 30 |
| Tráfico | 200 MB por día |
| Caché de mensajes | ~12 h |
| Tópicos reservados | Ninguno |
Planes pagos
| Plan | USD | Mensajes/día | Tópicos reservados | Adjuntos |
|---|---|---|---|---|
| Supporter | 5 | 2.500 | 3 | 25 MB |
| Pro | 10 | 20.000 | 10 | 250 MB |
| Business | 20 | 50.000 | 50 | 1 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
| Requisito | Mínimo | Nota |
|---|---|---|
| Sistema | Ubuntu 24.04 (amd64 o arm64) | Los comandos usan apt y systemd |
| RAM | 1 GB | ntfy + Caddy usan ~100 MB; agrega swap si tienes 1 GB |
| CPU | Cualquiera | Una fracción de vCPU sobra |
| Disco | ~2 GB libres | Caché de mensajes y logs |
| Red | IP pública fija | Debe estar encendida 24/7 |
| Acceso | SSH con un usuario con sudo | Los 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
| Puerto | Para qué |
|---|---|
| 22 | SSH |
| 80 | Validación de Let’s Encrypt (HTTP-01) y redirección a HTTPS |
| 443 | ntfy por HTTPS |
Ábrelos en todas las capas que tenga tu proveedor:
- El firewall de la nube (Security Group, Security List, Firewall de Hetzner…).
- El firewall dentro de la VM. Algunas imágenes traen
iptablescon unREJECTfinal 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 registroA(capítulo 04). - El registro debe existir antes de instalar Caddy.
ntfy requisitos vps firewall
DNS
El registro
| Tipo | Nombre | Contenido | Proxy | TTL |
|---|---|---|---|---|
A | ntfy | 203.0.113.10 | Sin 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
Acreado (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
| # | Paso | Por qué |
|---|---|---|
| 1 | Swap de 1 GB | En servidores de 1 GB, apt puede quedarse sin memoria |
| 2 | Abre 80/443 antes del REJECT de iptables | Algunas imágenes bloquean todo salvo SSH |
| 3 | Repos oficiales de ntfy y Caddy | Versiones al día con apt; detecta amd64/arm64 |
| 4 | server.yml | Ver abajo |
| 5 | Caddyfile | HTTPS automático y proxy |
| 6 | Usuario admin | Con deny-all, sin usuario nadie publica |
server.yml, línea por línea
| Opción | Valor | Por qué |
|---|---|---|
base-url | https://ntfy.tu-dominio.com | URLs correctas en la app y los adjuntos |
listen-http | 127.0.0.1:2586 | Solo local: el tráfico público entra por Caddy |
behind-proxy | true | Toma la IP real del cliente desde X-Forwarded-For |
cache-duration | 24h | Historial que ve la app al suscribirse |
auth-default-access | deny-all | Nada sin usuario o token |
upstream-base-url | https://ntfy.sh | Relay 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:
| Token | Dónde vive | Para qué |
|---|---|---|
mi-pc | Variable de entorno de tu PC | Avisos 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
| Forma | Ejemplo | Cuá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
--expirespara 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:
| Campo | Valor |
|---|---|
| URL del servicio | https://ntfy.tu-dominio.com |
| Usuario | admin (o un usuario de solo lectura) |
| Contraseña | La que definiste al instalar |
2. Suscribirte al tópico
Pantalla principal → +:
| Campo | Valor |
|---|---|
| Tópico | alertas (solo el nombre, sin URL ni barras) |
| Usar otro servidor | Activado → 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íntoma | Solució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 suenan | Ajustes de Android → Apps → ntfy → Notificaciones permitidas |
| Llegan tarde con la pantalla apagada | Ajustes → Batería → ntfy → Sin restricciones |
| iOS no recibe del servidor propio | Revisa upstream-base-url: "https://ntfy.sh" en server.yml |
| 401/403 al suscribirte | Usuario 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” usalow).high: suena y vibra (el reporte “FALLO” usahigh).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ódigo | Significado |
|---|---|
| 200 | Publicado |
| 401 / 403 | Sin token, token inválido o sin permiso en el tópico |
| 429 | Lí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.jsonqueda 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
.messagedel 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
/hooksuna 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ón | Cómo | Falla si |
|---|---|---|
| CPU y carga | vmstat 5 2, /proc/loadavg | Solo informativo |
| RAM, swap, disco | free -m, df | RAM ≥ 90 % o disco ≥ 85 % |
| Servicios | systemctl is-active ntfy caddy cron | Alguno inactivo |
| Ruta pública completa | curl https://<dominio>/v1/health desde el servidor | No responde healthy |
| Certificado | openssl s_client → días a notAfter | Menos de 10 días (la renovación falló) |
| Reinicio pendiente | /var/run/reboot-required | Solo informativo |
| Resultado | Título | Prioridad | Emoji |
|---|---|---|---|
| Todo bien | ntfy OK (hh:mm) | low (sin sonido) | ✅ |
| Algo falla | FALLO ntfy (hh:mm) + lista de fallas | high | 🚨 |
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
cronno puede expresar “cada 45 minutos”:*/45dispara a :00 y :45, con huecos de 15 min.OnUnitActiveSec=45mincuenta desde la última ejecución.OnBootSec=3minenví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
| Archivo | Contenido |
|---|---|
/var/lib/ntfy/user.db | Usuarios y tokens |
/etc/ntfy/server.yml | Configuración de ntfy |
/etc/caddy/Caddyfile | Proxy y TLS |
/etc/ntfy-report.env | Token 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
- Ejecuta
setup-ntfy.shen el nuevo (capítulo 05). - 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'
- 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
| Problema | Causa | Solución |
|---|---|---|
curl https://... se queda colgado | Puerto 443 cerrado en el firewall de la nube o en iptables | Abrir en ambas capas (capítulo 03) |
| Error de certificado | El DNS aún no apunta a la IP cuando arrancó Caddy | Esperar la propagación; journalctl -u caddy -n 50; sudo systemctl restart caddy |
| 502 Bad Gateway | ntfy caído | systemctl status ntfy |
apt muere en un servidor de 1 GB | Sin swap | El script crea 1 GB de swap |
Publicar y recibir
| Problema | Causa | Solución |
|---|---|---|
| 403 al publicar | auth-default-access: deny-all sin credenciales | -H "Authorization: Bearer tk_..." |
| 401 con token | Token revocado o mal copiado | sudo ntfy token list admin |
| Mensajes publicados pero no recibidos | Nadie suscrito a ese tópico | Suscribirse 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 suenan | Permisos u optimización de batería | Permitir notificaciones y “Sin restricciones” de batería |
| iOS no recibe | Falta el relay | upstream-base-url: "https://ntfy.sh" |
Claude Code
| Problema | Causa | Solución |
|---|---|---|
| La notificación nativa dice “Not sent — this terminal is active” | Se omite si estás en la terminal | Hook propio con ntfy (capítulo 09) |
| El hook no se dispara | Configuración no recargada | Abrir /hooks una vez o iniciar otra sesión |
settings.json deja de cargar | JSON inválido | jq -e . ~/.claude/settings.json |
Reporte de estado
| Problema | Causa | Solución |
|---|---|---|
| ”Cada 45 min” con cron dispara a :00 y :45 | */45 no es un intervalo real | Timer de systemd con OnUnitActiveSec=45min |
| El servidor caído no avisa | No puede reportar si está muerto | La ausencia de reporte por más de 45 min es la alarma |
| Demasiados avisos con sonido | Todos con prioridad alta | ”OK” con low, “FALLO” con high |
troubleshooting ntfy