SSD Nodes Learn 🎉 VPS desde $5.50/mes
Guías Matt ConnorPor Matt Connor

Cómo autohospedar ntfy para alertas de servidores

Instala ntfy en tu VPS con Docker Compose y TLS, protege los topics con usuarios y ACLs, y envía alertas desde cron y unidades systemd OnFailure.

Qué hace un servidor ntfy autohospedado

Un servidor ntfy autohospedado convierte una solicitud HTTP POST en una notificación push en el teléfono. Se publica con curl y el mensaje llega a la aplicación de Android, la aplicación de iOS, una pestaña del navegador o cualquier otro cliente que pueda mantener abierta una conexión HTTP. No es necesario instalar una biblioteca de cliente ni ejecutar un agente de mensajería.

ntfy direcciona los mensajes mediante un topic. Un topic es un nombre en la ruta URL, como https://ntfy.example.com/alerts, y existe desde el momento en que alguien publica en él. En una instalación predeterminada, cualquiera que conozca ese nombre puede leer el topic y publicar en él. Por eso, la documentación del proyecto compara el nombre de un topic con una contraseña. Este modelo es adecuado para el servicio público ntfy.sh. No lo es para un servidor que transporta los fallos de sus copias de seguridad. Por eso, esta guía activa la autenticación antes de enviar el primer mensaje.

Qué necesita antes de empezar

Necesita un VPS con Ubuntu 24.04 o Debian 13, Docker Engine y el plugin Compose, un nombre de dominio y muy poca RAM. Cree un registro A de DNS (sistema de nombres de dominio) que apunte ntfy.example.com a la dirección IP pública del servidor. Después, confirme que resuelve antes de hacer cualquier otra cosa.

dig +short ntfy.example.com
sudo ufw allow 80,443/tcp
sudo ufw status

dig debe mostrar la IP del servidor. La emisión del certificado falla si no muestra nada, porque la autoridad certificadora comprueba el nombre desde el exterior. El puerto 80 permanece abierto porque ACME (entorno de gestión automática de certificados), el protocolo en el que se basa Let's Encrypt, lo usa para el desafío HTTP. El contenedor de ntfy no expone ningún puerto público.

Escriba el archivo de configuración de ntfy

La imagen de Docker no contiene un archivo de configuración, así que debe crear uno. Todos los comandos posteriores de esta guía lo leen. Primero, busque el ID de usuario y el ID de grupo con los que se ejecutará el contenedor.

id -u
id -g
sudo install -d -o "$(id -u)" -g "$(id -g)" /etc/ntfy /var/cache/ntfy /var/lib/ntfy
sudo nano /etc/ntfy/server.yml
base-url: "https://ntfy.example.com"
listen-http: ":2586"
behind-proxy: true
cache-file: "/var/cache/ntfy/cache.db"
cache-duration: "12h"
auth-file: "/var/lib/ntfy/user.db"
auth-default-access: "deny-all"
enable-login: true
enable-signup: false

Cuatro de esas líneas son fundamentales. base-url debe ser la dirección HTTPS pública exacta, porque ntfy genera a partir de ella los enlaces a los archivos adjuntos y las solicitudes de la propia aplicación web. Un valor incorrecto hace que la aplicación web cargue y después falle en todas las operaciones. listen-http: ":2586" se enlaza a todas las interfaces dentro del contenedor. Esto parece poco restrictivo, pero es correcto: el contenedor tiene su propio espacio de nombres de red, por lo que enlazarse a 127.0.0.1 allí impediría acceder al puerto desde el host y el puerto publicado por Docker nunca podría conectarse. auth-default-access: "deny-all" define toda la política de seguridad, porque rechaza las operaciones de lectura y escritura de cualquier usuario que no tenga una autorización explícita. behind-proxy: true indica a ntfy que obtenga la dirección del cliente de la cabecera X-Forwarded-For, de modo que los límites de tasa cuenten a los visitantes reales en lugar de contar el reverse proxy como un único cliente muy activo.

enable-login: true permite que la aplicación web y las aplicaciones móviles inicien sesión con una contraseña. enable-signup permanece en false, porque permitir que los usuarios creen cuentas por sí mismos en un servidor privado deja una vía de acceso innecesaria.

sudo chown "$(id -u):$(id -g)" /etc/ntfy/server.yml
sudo chmod 600 /etc/ntfy/server.yml

Ejecutar ntfy con Docker Compose

Ponga esto en /opt/ntfy/compose.yaml y sustituya 1000:1000 por los dos números id -u y id -g mostrados arriba.

services:
  ntfy:
    image: binwiederhier/ntfy:v2.27.0
    container_name: ntfy
    command: serve
    user: "1000:1000"
    environment:
      - TZ=UTC
    volumes:
      - /etc/ntfy:/etc/ntfy
      - /var/cache/ntfy:/var/cache/ntfy
      - /var/lib/ntfy:/var/lib/ntfy
    ports:
      - "127.0.0.1:2586:2586"
    restart: unless-stopped
cd /opt/ntfy
sudo docker compose up -d
sudo docker compose logs ntfy
curl -s http://127.0.0.1:2586/v1/health

Un servidor operativo responde en {"healthy":true}. Hay dos detalles intencionados en ese archivo de Compose. La imagen está fijada en v2.27.0, la versión actual en agosto de 2026, en lugar de latest, porque con latest el siguiente docker compose pull cambia la versión del servidor y usted se entera después al leer el registro de cambios. El puerto se publica como 127.0.0.1:2586:2586, por lo que el contenedor sólo es accesible desde la dirección de loopback del host. Si escribe 2586:2586, Docker inserta sus propias reglas de firewall antes que las suyas. Por eso el puerto responde desde Internet aunque ufw status indique que está cerrado.

Si curl muestra Connection refused, lea el registro del contenedor. Un error de permisos en /var/lib/ntfy/user.db significa que la línea user: no coincide con el propietario de esos directorios. Por eso el proceso no puede crear su propia base de datos y termina. La guía básica de Docker Compose para un VPS explica con más detalle la propiedad de los volúmenes y las políticas de reinicio.

Coloque TLS delante con Caddy

Caddy solicita y renueva el certificado por su cuenta. Es la forma más rápida de disponer de TLS (seguridad de la capa de transporte).

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy

Sustituya el contenido de /etc/caddy/Caddyfile por tres líneas.

ntfy.example.com {
    reverse_proxy 127.0.0.1:2586
}
sudo systemctl reload caddy
curl -s https://ntfy.example.com/v1/health

El mismo {"healthy":true} mediante HTTPS confirma que toda la ruta funciona. Un 502 de Caddy indica que ntfy no está escuchando. Compruébelo con sudo ss -lntp | grep 2586. Un error de certificado suele indicar que el registro DNS es incorrecto o que el puerto 80 está bloqueado. sudo journalctl -u caddy -n 50 indica cuál de los dos problemas se ha producido.

Si ya utiliza nginx, copie la configuración del proxy que documenta ntfy: proxy_http_version 1.1, proxy_buffering off, proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for, y establezca tiempos de espera de lectura y envío de al menos tres minutos. Un suscriptor mantiene abierta una conexión HTTP mientras está escuchando. nginx cierra de forma predeterminada una conexión upstream inactiva después de 60 segundos. Por eso, los suscriptores se vuelven a conectar en un bucle y se pierden los mensajes enviados durante la interrupción.

Crear usuarios y restringir el acceso a los temas

La autenticación está activada y todavía nadie tiene acceso a nada. Ese es el objetivo. Cree una cuenta de administrador para usted y una cuenta de máquina para los scripts. Estos comandos leen /etc/ntfy/server.yml desde el contenedor, por eso el archivo de configuración está montado como volumen.

sudo docker compose exec ntfy ntfy user add --role=admin admin
sudo docker compose exec ntfy ntfy user add robot
sudo docker compose exec ntfy ntfy user list

Cada comando solicita una contraseña. Un administrador ignora la lista de control de acceso y puede leer y escribir en todos los temas. Reserve esa cuenta para usted y para la aplicación del teléfono. robot es un usuario normal sin ningún acceso hasta que le conceda permisos.

sudo docker compose exec ntfy ntfy access robot alerts write
sudo docker compose exec ntfy ntfy access robot "alerts_*" write
sudo docker compose exec ntfy ntfy access

Una entrada de una ACL (lista de control de acceso) incluye un usuario, un tema y un permiso. El tema puede ser un nombre literal o un patrón en el que * coincide con cualquier valor. Por tanto, alerts_* cubre alerts_backup y alerts_db sin ejecutar un comando para cada host. El permiso write significa que sólo puede publicar. Así, un token robado de una tarea de cron no puede suscribirse ni leer los datos que envió. El nombre de usuario especial everyone define lo que puede hacer un visitante no autenticado. Úselo sólo para abrir algo de forma deliberada al público, como ntfy access everyone status read.

Los scripts deben usar un token, no su contraseña.

sudo docker compose exec ntfy ntfy token add robot

El comando imprime un token que comienza por tk_. Un token hereda exactamente los permisos del usuario al que pertenece. Por tanto, este token puede publicar en los temas alerts y no puede hacer nada más. ntfy token list muestra los elementos existentes y ntfy token remove revoca uno sin modificar la contraseña del usuario.

Envía tu primer mensaje y comprueba que el bloqueo funciona

Empieza comprobando que la puerta está cerrada.

curl -s -o /dev/null -w '%{http_code}\n' -d "hello" https://ntfy.example.com/alerts

Esto muestra 403, y 403 es la respuesta correcta: auth-default-access: "deny-all" rechaza una publicación anónima. Ahora envía un mensaje real.

curl -H "Authorization: Bearer tk_REPLACE_WITH_YOUR_TOKEN" \
  -H "Title: Nightly backup finished" \
  -H "Priority: default" \
  -H "Tags: white_check_mark" \
  -d "42 GB copied in 11 minutes" \
  https://ntfy.example.com/alerts

El servidor responde con el mensaje almacenado en formato JSON. Así sabes que lo aceptó en lugar de descartarlo. Title es la primera línea en negrita. Priority va de 1 a 5, o por nombre desde min hasta urgent, y determina si el teléfono emite un sonido. Tags se convierten en emoji en la notificación cuando el nombre coincide con un código corto de emoji conocido, y permanecen como texto sin formato cuando no coincide.

Para supervisar un tema desde un terminal, transmítelo:

curl -s -u admin https://ntfy.example.com/alerts/raw

curl solicita la contraseña. Cada mensaje llega en una línea, y las líneas en blanco que aparecen ocasionalmente son mensajes de mantenimiento de conexión. Abrir https://ntfy.example.com en un navegador e iniciar sesión con la misma cuenta muestra la versión web de la misma transmisión.

Establezca límites de tasa para que un script no pueda saturar el servidor

De forma predeterminada, cada visitante recibe un cupo de 60 solicitudes, que se repone a razón de una solicitud cada 5 segundos. Es un límite amplio para un servidor privado, y un script atrapado en un bucle de reintentos lo consumirá por completo. Añada límites a server.yml.

visitor-request-limit-burst: 30
visitor-request-limit-replenish: "10s"
visitor-message-daily-limit: 500
sudo docker compose restart ntfy

Un visitante que supera el límite recibe HTTP 429 en lugar de un mensaje entregado. El límite se cuenta por dirección del visitante. Por eso behind-proxy: true es tan importante: sin esta opción, ntfy sólo ve la dirección de Caddy, todos los clientes cuentan como el mismo visitante y un script ruidoso agota el cupo que comparten el teléfono y los demás servidores.

Alerta de un trabajo de cron que falla

No incluya el token en la línea de comandos. ps aux muestra la línea de comandos completa de todos los procesos en ejecución a cualquier usuario del sistema, por lo que un token pasado con -H puede leerlo cualquier cuenta local mientras curl esté en ejecución. Un archivo de configuración de curl evita ese problema.

sudo install -d -m 700 /etc/ntfy-alert
printf 'header = "Authorization: Bearer tk_REPLACE_WITH_YOUR_TOKEN"\n' | sudo tee /etc/ntfy-alert/curlrc
sudo chmod 600 /etc/ntfy-alert/curlrc

Ahora envuelva el trabajo. Guárdelo como /usr/local/bin/backup-with-alert.sh y haga chmod 750 con él.

#!/bin/bash
out=$(/usr/local/bin/backup.sh 2>&1)
code=$?
if [ "$code" -ne 0 ]; then
  printf '%s' "$out" | tail -c 1000 | curl -K /etc/ntfy-alert/curlrc \
    -H "Title: backup.sh failed with exit $code" \
    -H "Priority: high" \
    -H "Tags: warning" \
    --data-binary @- \
    https://ntfy.example.com/alerts
fi
exit "$code"
17 3 * * * /usr/local/bin/backup-with-alert.sh >> /var/log/backup-alert.log 2>&1

$? se captura en la línea inmediatamente posterior al comando, porque el siguiente comando que se ejecute lo sobrescribiría. La salida pasa por tail -c 1000 porque ntfy aplica un tamaño máximo al mensaje y una notificación no sustituye a un visor de registros. El exit "$code" de cierre conserva el estado original, por lo que cualquier otro componente que supervise este trabajo seguirá viendo un fallo. Pruebe todo el proceso haciendo que el script apunte a /bin/false durante una ejecución.

Una rama de error que nunca se ejecuta es peor que no tener alertas, porque da la impresión de que el silencio significa que todo funciona. Cron proporciona al trabajo un entorno casi vacío y un PATH mucho más corto que el de su shell de inicio de sesión, por lo que un script que funciona al ejecutarlo manualmente puede terminar antes de llegar a la línea de curl. La guía sobre por qué no se ejecuta un trabajo de cron explica estos problemas de entorno. Use rutas absolutas en todo el script y lea el archivo de registro después de la primera ejecución programada, en lugar de dar por hecho que todo funciona.

Alertar cuando falle una unidad de systemd

Cron cubre las tareas programadas. Los servicios de larga duración necesitan OnFailure=, que systemd ejecuta cada vez que una unidad entra en el estado failed. Cree una unidad de plantilla y reutilícela para todos los servicios del servidor. Guárdela como /etc/systemd/system/ntfy-unit-failed@.service.

[Unit]
Description=Send an ntfy alert because %i failed

[Service]
Type=oneshot
ExecStart=/usr/local/bin/ntfy-unit-failed %i

Después /usr/local/bin/ntfy-unit-failed, con el modo 750:

#!/bin/bash
unit="$1"
journalctl -u "$unit" -n 15 --no-pager -o cat | tail -c 1000 | curl -K /etc/ntfy-alert/curlrc \
  -H "Title: $unit failed on $(hostname -s)" \
  -H "Priority: urgent" \
  -H "Tags: rotating_light" \
  --data-binary @- \
  https://ntfy.example.com/alerts

Asóciela a un servicio con un drop-in para que una actualización de paquetes no sobrescriba los cambios.

sudo systemctl edit myapp.service
[Unit]
OnFailure=ntfy-unit-failed@%n.service

%n se expande al nombre completo de la unidad, por lo que la instancia se convierte en ntfy-unit-failed@myapp.service, y %i dentro de la plantilla entrega myapp.service al script como su primer argumento. Esto permite que una sola plantilla sirva para todas las unidades. Compruebe que funciona con una unidad que falle de forma intencionada, guardada como /etc/systemd/system/ntfy-selftest.service.

[Unit]
Description=Deliberately failing unit
OnFailure=ntfy-unit-failed@%n.service

[Service]
Type=oneshot
ExecStart=/bin/false
sudo systemctl daemon-reload
sudo systemctl start ntfy-selftest.service

El comando de inicio termina con un código distinto de cero y muestra Job for ntfy-selftest.service failed because the control process exited with error code. El teléfono debería emitir una alerta aproximadamente un segundo después. Elimine la unidad de prueba al terminar.

Hay un detalle importante. OnFailure= sólo se ejecuta cuando una unidad alcanza el estado failed, y un servicio con Restart=always puede no alcanzarlo nunca, porque systemd lo reinicia continuamente. La unidad sólo falla cuando supera StartLimitBurst reinicios dentro de StartLimitIntervalSec. Establezca esos dos valores en cualquier servicio del que quiera recibir alertas. De lo contrario, un bucle de fallos puede ejecutarse silenciosamente durante días. Los timers son un reemplazo más limpio del patrón con cron anterior, porque la unidad de servicio de un timer recibe OnFailure= automáticamente. la guía sobre servicios y timers de systemd en un VPS explica cómo convertir uno.

Conecte un monitor de disponibilidad al mismo tema

Uptime Kuma, el monitor de estado autohospedado, incluye un tipo de notificación para ntfy. Abra Settings, después Notifications y luego Setup Notification. Seleccione Ntfy, establezca la URL del servidor en https://ntfy.example.com y el tema en alerts, elija una prioridad y pegue el token de acceso robot. Envíe la notificación de prueba antes de guardar, porque un nombre de tema incorrecto falla de forma silenciosa con un permiso write que no lo incluye.

El límite real de esta configuración es el siguiente: un monitor que se ejecuta en el mismo VPS no puede informarle de que el VPS está caído, y ntfy no puede entregar el aviso de que ntfy está caído. Ejecute el monitor en otra máquina y asígnele un segundo canal de notificación, como el correo electrónico, para el monitor que supervisa el propio ntfy. El tipo de monitor Push de Uptime Kuma cubre el otro punto ciego: el trabajo de cron llama a una URL de push después de ejecutarse correctamente, y Kuma genera una alerta cuando esas llamadas dejan de llegar. Una rama de error sólo se activa cuando se ejecuta el trabajo, por lo que no informa de un trabajo que nunca se inició.

¿ntfy autohospedado funciona en Android y iPhone?

En Android, sí, sin restricciones. Instale la aplicación desde Google Play o F-Droid, abra Settings, establezca el servidor predeterminado en https://ntfy.example.com, añada su cuenta en la pantalla de gestión de usuarios y suscríbase a alerts. La entrega instantánea mantiene un servicio en primer plano activo para que los mensajes lleguen incluso cuando el teléfono está en modo doze. La notificación permanente que la acompaña es un requisito de Android para los servicios en primer plano, no un error. La compilación de F-Droid no contiene código de Firebase, por lo que todas las suscripciones usan la entrega instantánea. ntfy también puede actuar como distribuidor de UnifiedPush, una alternativa abierta al servicio de notificaciones push de Google. Por tanto, otras aplicaciones compatibles con UnifiedPush también pueden enviar notificaciones a través de su servidor.

En iOS, funciona con una dependencia que no puede eliminar. Apple sólo activa una aplicación en segundo plano mediante APNs (Apple push notification service), y sólo la entidad que posee las credenciales de firma de la aplicación puede enviarle notificaciones. Por tanto, su servidor no puede acceder directamente a la aplicación. ntfy resuelve esto mediante un relay: su servidor envía un poll_request que contiene el ID del mensaje a ntfy.sh. ntfy.sh lo reenvía mediante Firebase y APNs para activar la aplicación. Después, la aplicación obtiene el cuerpo del mensaje desde su servidor.

upstream-base-url: "https://ntfy.sh"

Tenga claro cuál es el coste. El contenido del mensaje permanece en su servidor, pero el hecho de que haya llegado un mensaje y su ID pasan por una infraestructura que usted no administra. Sin esta configuración, las notificaciones de un servidor autohospedado en iPhone llegan tarde o no llegan, porque nada activa la aplicación. La única forma de eliminar el relay es compilar y distribuir usted mismo la aplicación de iOS con su propia cuenta de desarrollador de Apple y sus propias claves de APNs. Esto implica una cuota anual y una nueva compilación para cada actualización. Si el relay no es aceptable para su caso de uso, mantenga las alertas en Android o en la aplicación web de escritorio.

Copias de seguridad, actualizaciones y fijación de la imagen

Hay dos rutas que no se pueden regenerar: /etc/ntfy/server.yml y /var/lib/ntfy/user.db. La segunda contiene todos los usuarios, hashes de contraseñas, entradas de ACL y tokens, por lo que debe tratarse como una clave privada.

sudo tar czf ntfy-backup.tgz -C / etc/ntfy var/lib/ntfy
sudo chmod 600 ntfy-backup.tgz

Copie ese archivo fuera del servidor. cache.db sólo contiene mensajes recientes, correspondientes a 12 horas junto con el cache-duration anterior, por lo que perderlo no implica ningún dato que merezca protegerse. Para actualizar, edite la etiqueta en el archivo de Compose y ejecute pull.

sudo docker compose pull
sudo docker compose up -d
curl -s https://ntfy.example.com/v1/health

Lea primero las notas de la versión. Las bases de datos SQLite se migran al iniciar, por lo que no es seguro volver a una etiqueta anterior después de un cambio de esquema. Conserve la copia de seguridad que acaba de realizar hasta que la nueva versión haya funcionado durante un día.

Gotify y Apprise

Gotify es la opción más pequeña: un único binario con una interfaz web y una aplicación para Android. No admite comodines de temas ni ofrece un cliente oficial para iOS. Esto encaja con un servidor privado cuyo único destino sea Android. Apprise es una biblioteca de Python y una herramienta de línea de comandos, no un servidor. Distribuye un mismo mensaje a más de cien servicios, incluido ntfy. Esto resulta adecuado para un script que debe enviar alertas a varios destinos al mismo tiempo. ntfy es la opción que proporciona un servidor, una API HTTP y aplicaciones para ambas plataformas móviles. Por eso suele ser la respuesta habitual para enviar alertas desde un servidor alquilado.

FAQ

¿Por qué publicar en mi servidor ntfy devuelve 403?

Con auth-default-access: "deny-all" en server.yml, las publicaciones anónimas se rechazan. Ese es el comportamiento previsto. Envíe las credenciales con -u user:pass o -H "Authorization: Bearer tk_...". Si ya envía un token y sigue obteniendo 403, el usuario asociado a ese token no tiene una entrada ACL que coincida con el topic. Ejecute ntfy access para mostrar la lista completa. Recuerde que una concesión write no permite suscribirse. Por tanto, una cuenta que puede publicar correctamente seguirá siendo rechazada cuando intente leer el mismo topic.

¿Funcionan las notificaciones en iPhone con un servidor ntfy autoalojado?

Funcionan mediante un relay que no se puede evitar. Apple sólo activa las aplicaciones mediante APNs (Apple push notification service), y sólo el editor de la aplicación puede enviarle notificaciones. Por eso, ntfy reenvía a ntfy.sh un poll_request que contiene el ID del mensaje, y ntfy.sh lo retransmite al dispositivo. Configure upstream-base-url: "https://ntfy.sh" en server.yml y reinicie el contenedor. El cuerpo del mensaje se sigue obteniendo desde su servidor. Sin esta configuración, las notificaciones de iOS se retrasan o no aparecen.

¿Por qué nunca llegó la alerta de ntfy de mi trabajo de cron?

Ejecute primero la línea de curl por separado para confirmar que el token y el topic son correctos. Si funciona manualmente pero no desde cron, el fallo se produce antes de la alerta: cron ejecuta los trabajos con un entorno mínimo y un PATH corto. Por eso, un script que llama a un comando por su nombre sin ruta absoluta puede terminar antes de llegar a la línea de curl. Use rutas absolutas, redirija la salida del trabajo a un archivo de registro y lea ese archivo después de la siguiente ejecución. Una respuesta 429 en lugar de una entrega indica que el límite de tasa funciona y que el script reintenta demasiado rápido.

¿Debería exponer ntfy en Internet público?

Las aplicaciones móviles deben acceder al servidor desde redes móviles. Por eso, un endpoint HTTPS público con auth-default-access: "deny-all" y ACL por topic es la configuración habitual. Es segura siempre que ningún topic pueda ser leído por everyone. Una instancia accesible sólo mediante VPN es adecuada cuando todos los suscriptores son máquinas que usted controla. No es una buena opción para teléfonos, porque la aplicación sólo recibe notificaciones mientras el túnel está activo. Las alertas se acumulan hasta que el teléfono vuelve a conectarse.