··
Una notificación push cada día a las 20:00 confirma que todo sigue vivo. El silencio de un día es lo que de verdad da miedo.

Lo primero que aprendí montando servicios self-hosted es que el monitor se te cae contigo. Si tu Uptime Kuma vive en el mismo VPS que tu blog y el VPS se apaga, nadie te avisa. La alerta nunca sale. Cuando vuelves al día siguiente y abres la app, la última línea verde es de ayer y no te enteras hasta que ya han pasado nueve horas.
Así que invertí la regla. En vez de pedir al sistema que me avise cuando algo falle, le pedí que me avisara cuando algo sigue vivo. Si la señal llega, todo bien. Si el silencio dura un día, hay algo que mirar.
Tengo un ntfy corriendo en el proyecto de infraestructura dentro de Dokploy. Cada servicio que despliego publica en un tópico distinto. El blog publica en blog-alerts. Entre los eventos que me llegan hay cosas reactivas, como un fallo de migración al arrancar, un rate limit saltado en autenticación o un backup descargado desde el admin. Todos tienen su sentido y todos llevan su prioridad.
Pero el que más me dice sobre la salud general del sistema no es ninguno de esos. Es el heartbeat. Un mensaje diario, siempre a la misma hora, que dice literalmente que el proceso sigue en pie.
La primera versión que escribí era un setInterval de 24 horas arrancado al primer request del servidor. Funcionaba dos días. Después el process manager reciclaba el worker, el timer se iba con él y no lo notaba hasta que volvía a desplegar.
También probé un cron dentro del contenedor. Funciona, pero mete una dependencia nueva como cron o supercronic, duplica el PID 1 y no arranca hasta que algún script lo lanza. Para un Next.js pequeño en modo standalone no compensaba.
El camino que terminé usando vive dentro de la propia app, en src/lib/heartbeat.ts, y se arranca desde src/instrumentation.ts. Así siempre está atado al ciclo de vida del servidor Next. Si Next no arranca, no hay heartbeat. Que es exactamente la señal que quiero recibir, o mejor dicho, la señal que quiero echar en falta.
La primera implementación seria calculaba el próximo disparo con new Date() y se fiaba de la zona horaria del contenedor. Alpine viene en UTC por defecto. Yo quería que el heartbeat me llegara a las 20:00 de Madrid, no a las 20:00 UTC. En verano la diferencia es de dos horas, en invierno de una. Y el cambio DST pasa a medianoche un sábado, así que si tu lógica suma segundos a pelo y tu contenedor no tiene tzdata, te toca ajustar a mano dos veces al año.
El arreglo fue dejar de tocar el TZ del contenedor y calcular el próximo disparo con Intl.DateTimeFormat. Le paso la zona Europe/Madrid y la hora objetivo, y me devuelve un timestamp local convertido correctamente aunque el host viva en UTC. DST deja de ser un problema porque el cálculo lo hace el motor con las reglas IANA, no mi código.
function msUntilNext(hour: number, tz: string): number {
const now = Date.now();
const fmt = new Intl.DateTimeFormat('en-GB', {
timeZone: tz,
hour: 'numeric', minute: 'numeric', second: 'numeric',
year: 'numeric', month: '2-digit', day: '2-digit',
hour12: false,
});
// reconstruye la hora local en tz, ajusta si ya pasó
// y devuelve ms restantes hasta el próximo disparo
// ...
}La implementación completa vive en el fichero junto con tests que simulan varios husos para asegurar que el cálculo no se desvía. Si tu caso es parecido, el detalle importante es no confiar en la zona horaria del proceso y preguntársela siempre al formatter.
El heartbeat no dice solo hola estoy vivo. Dice algo que me permite ver de un vistazo si hay algo raro. Uptime formateado, memoria si me apetece, versión de la app si acabo de actualizar. En la práctica es suficiente con el uptime y una cabecera corta. Prioridad 2, para que no me saque del modo no molestar.
await notify({
title: 'Blog vivo',
message: `Uptime ${formatUptime(process.uptime())}`,
priority: 2,
tags: ['heart'],
});Si un día falta, no se oye nada. No suena la app, no vibra. Esa es la señal. La ausencia.
Al principio pensé que detectar la ausencia era complicado y que iba a tener que montar otro servicio. Me equivoqué. La app del móvil de ntfy muestra una línea por mensaje y ordena por fecha. Si entro a las 21:00 y la última línea es de ayer, lo veo en un segundo. No hay que montar nada más.
Para el caso en el que yo no mire el móvil hay una segunda red. El topic lo reciben los otros proyectos self-hosted del mismo servidor. Si el blog no publica el heartbeat dos días seguidos, un script en otro servicio lo detecta y me manda un aviso por Telegram. Es redundancia barata.
Slack y Telegram funcionan y los podría usar. La razón por la que escogí ntfy es que vive en mi red interna de Docker y no depende de un token externo que pueda caducar o que pueda exfiltrarse si la imagen se filtra. El contenedor habla a http://ntfy-backend:80, la URL no es pública, y si alguien se hace con mi topic solo puede enviarme ruido, no leer mis mensajes.
Además ntfy es pequeño. Un binario en Go, cero dependencias, cero configuración, cabe en cualquier VPS. Es el tipo de pieza que quieres que sobreviva al resto.
Una vez tienes el patrón, la siguiente tentación es replicarlo en todos tus servicios. Un heartbeat por servicio, cada uno en su topic. El CV publica el suyo, ScamDetector el suyo, MyBox el suyo. Si tres días seguidos falta el heartbeat de uno de ellos y los demás sí llegan, ya sabes dónde mirar sin entrar al VPS.
Lo que no aconsejo es publicar el heartbeat cada hora. Pierdes la señal en el ruido. Una vez al día, siempre a la misma hora, mejor en un momento en el que estés mirando el móvil por otras razones. Que el hueco sea visible.
Entre lo que ya tenía y el arreglo de la zona horaria, unas dos horas de trabajo. CPU y RAM indetectables, el timer duerme casi todo el día. Coste monetario cero, ntfy corre en el VPS que ya tenía. Lo único que me habría ahorrado es escribir la primera versión antes de pensar en el cambio de hora.
El resultado es que si el blog se muere en mitad de la noche, a las 20:00 del día siguiente no aparece nada en el móvil. Es la peor notificación del mundo, la que no llega. Y por eso funciona.

Jose, autor del blog
QA Engineer. Escribo en voz alta sobre automatización, IA y arquitectura de software. Si algo te ha servido, escríbeme y cuéntamelo.
¿Qué te ha parecido? ¿Qué añadirías? Cada comentario afina la siguiente entrada.
Si esto te ha gustado

Comparativa transversal de AI Gateways en 2026 por ejes que pesan de verdad, catálogo, coste, latencia, observabilidad, failover, privacidad, operativa y lock-in. Cuándo elegir cada uno y por qué no son excluyentes.

Tres medidas concretas para proteger tu Dockerfile contra ataques de supply chain: verificación de checksums con SHA256, control de scripts npm con ignore-scripts y eliminación del package manager en la imagen de producción.

Las variables de entorno en texto plano son cómodas hasta que dejan de ser seguras. Explicamos cómo desplegamos Infisical como gestor de secretos self-hosted dentro de Dokploy y cómo conectamos nuestras aplicaciones para que lean las credenciales de forma cifrada y auditable.