Homelab
Uptime Kuma: vigila tus servicios y enseña su estado en vivo en tu CV
8 min de lectura
Puntos clave
- Uptime Kuma en Docker, publicado solo en 127.0.0.1 y con límite de memoria de 512 MB.
- Monitores por URL pública para lo público, por IP y puerto para lo interno, y TCP para bases de datos.
- Notificaciones desde el primer día: un monitor sin avisos no sirve de nada.
- Caddy publica solo la página de estado y su API; el panel queda en la LAN/VPN.
- Un widget en JS vanilla muestra el estado en tu web y se oculta si la API falla.
En mi portfolio hay un widget llamado “Estado en vivo”. Muestra si mis servicios están funcionando ahora mismo y su disponibilidad real. No es una captura ni un texto que yo escribo: lo alimenta la página de estado pública de Uptime Kuma, que vigila mis servicios cada minuto.
Para alguien que revisa mi CV es una prueba que puede comprobar: no digo que mantengo servicios en producción, lo enseño. En este capítulo cuento cómo lo monté y los fallos que me encontré por el camino.
Qué vas a montar
- Uptime Kuma en Docker, accesible solo a través del proxy.
- Monitores bien elegidos y notificaciones desde el primer día.
- Una página de estado pública con solo lo que debe verse.
- Caddy publicando esa página y su API, y el panel cerrado a la LAN/VPN.
- Un widget en JavaScript para mostrar el estado en tu web.
Requisitos
- Docker y Docker Compose en el NAS.
- Caddy delante (ver el capítulo 2 de la serie). En los ejemplos,
uptime.example.com. - En los ejemplos,
192.168.1.10es la IP de tu NAS. Es un valor de ejemplo: usa la tuya.
Paso 1: el docker-compose.yml
services:
uptime-kuma:
image: louislam/uptime-kuma:1 # rama 1: la 2 cambia la base de datos
container_name: uptime-kuma
ports:
- "127.0.0.1:3004:3001" # solo el proxy llega a él
volumes:
- ./data:/app/data # base de datos y configuración
mem_limit: 512m # con 256m se quedaba sin memoria
healthcheck:
test: ["CMD", "extra/healthcheck"] # la imagen 1 no trae wget
interval: 60s
timeout: 10s
retries: 3
restart: unless-stopped
Cada línea tiene su historia:
- La etiqueta de la imagen. Me quedo en
:1a propósito. La versión 2 cambia el esquema de la base de datos, y esa migración quiero hacerla con calma. Además, aprendí que fijar una etiqueta muy concreta también tiene riesgo: tenía:1.23, esa etiqueta desapareció del registro y el contenedor no se pudo recrear. Fija la versión, sí, pero revísala de vez en cuando. 127.0.0.1:3004. El puerto solo escucha en el propio NAS. Desde la red no se llega; solo Caddy.mem_limit: 512m. Con 256 MB y unos 14 monitores, el contenedor moría por falta de memoria (OOM). Con 512 MB va holgado.- El healthcheck. Mi primer healthcheck usaba
wget, pero la imagen 1 no lo trae. Resultado: el contenedor aparecía comounhealthyaunque funcionaba perfectamente. La imagen incluye su propio comprobador,extra/healthcheck: usa ese.
docker compose up -d
docker ps --filter name=uptime-kuma # espera a ver "healthy"
La primera vez que entres, Uptime Kuma te pide crear el usuario administrador.
Paso 2: monitores que de verdad detectan fallos
Mi primer intento fue apuntar los monitores a nombres de contenedor, como http://grafana:3000. Fallaban todos con getaddrinfo ENOTFOUND. Los nombres de contenedor solo se resuelven dentro de la misma red Docker, y cada servicio estaba en la suya.
Ahora sigo esta regla:
| Tipo de servicio | Monitor | Por qué |
|---|---|---|
| Público | HTTP(s) a la URL pública (https://app.example.com) |
Prueba toda la cadena: DNS, proxy, certificado y servicio |
| Interno | HTTP(s) a http://192.168.1.10:PUERTO |
No depende de redes Docker |
| Base de datos | TCP Port a 192.168.1.10 y su puerto |
Comprueba que acepta conexiones |
Dos ajustes en los monitores HTTP(s) públicos:
- Códigos aceptados:
200-399. Muchos servicios responden con una redirección al login, y eso no es un fallo. - Aviso de caducidad del certificado activado. Si la renovación falla, te enteras antes de que caduque.
Si un servicio interno solo escucha en 127.0.0.1 (como Uptime Kuma o Jellyfin en esta serie), no se llega por la IP del NAS: vigílalo por su URL del proxy.
Paso 3: notificaciones desde el primer día
Este fallo me dolió. Un contenedor llevaba semanas parado y nadie se enteró. Tenía el monitor, pero no los avisos. Un monitor que nadie mira es exactamente lo mismo que no tener monitor.
Uptime Kuma admite muchos canales: Telegram, ntfy, correo electrónico y decenas más. Configura al menos uno en los ajustes de notificaciones, márcalo para que se aplique por defecto a los monitores nuevos y pruébalo con el botón de test antes de fiarte.
Paso 4: la página de estado pública
En Uptime Kuma, crea una página de estado con un slug corto, por ejemplo portfolio. Quedará en https://uptime.example.com/status/portfolio.
Añade solo los servicios públicos. Nada de paneles de administración, bases de datos ni servicios internos: una página de estado también le dice a un atacante qué tienes y dónde.
Paso 5: Caddy publica la página, no el panel
Quiero que cualquiera vea la página de estado, pero que el panel solo se abra desde casa o la VPN. El truco es un matcher con dos condiciones: la ruta no es de la página de estado y la IP no es de casa. Si se cumplen las dos, 403.
uptime.example.com {
import security_headers
# Todo lo que no sea la página de estado, solo desde LAN/VPN
@privado {
not path /status /status/* /api/status-page/* /api/entry-page /assets/* /icon.svg /favicon.ico /manifest.json /upload/*
not remote_ip 192.168.1.0/24 10.8.0.0/24 127.0.0.1/8
}
respond @privado 403
# CORS para que tu web pueda leer la API de la página de estado
@statusapi path /api/status-page/*
header @statusapi Access-Control-Allow-Origin "*"
header @statusapi Access-Control-Allow-Methods "GET, OPTIONS"
header @statusapi Vary "Origin"
reverse_proxy localhost:3004
}
Las rutas permitidas son las que necesita la página de estado para cargar: la propia página, su API, los estilos y scripts (/assets/*), los iconos y las imágenes subidas (/upload/*). Las redes son las de ejemplo del capítulo 2 (192.168.1.0/24 para casa, 10.8.0.0/24 para la VPN).
Abrir CORS con * aquí no es un riesgo: son datos públicos y de solo lectura, lo mismo que ve cualquiera en la página.
Paso 6: el estado en tu web o CV
La página de estado usa dos endpoints públicos que tú también puedes leer:
GET https://uptime.example.com/api/status-page/portfolio: los grupos y monitores de la página (nombre e id de cada uno).GET https://uptime.example.com/api/status-page/heartbeat/portfolio: los últimos latidos de cada monitor y su porcentaje de disponibilidad.
Con eso monto el widget. Primero, el contenedor en tu HTML, oculto por defecto:
<section id="estado-en-vivo" hidden>
<h2>Estado en vivo</h2>
<ul></ul>
<a href="https://uptime.example.com/status/portfolio">Ver la página de estado</a>
</section>
Y el script, en JavaScript sin librerías:
const BASE = "https://uptime.example.com";
const SLUG = "portfolio";
const widget = document.getElementById("estado-en-vivo");
const list = widget.querySelector("ul");
// Códigos de estado de Uptime Kuma
const STATUS = { 0: "Caído", 1: "Operativo", 2: "Pendiente", 3: "Mantenimiento" };
async function getJson(url) {
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
async function refresh() {
try {
const [page, beats] = await Promise.all([
getJson(`${BASE}/api/status-page/${SLUG}`),
getJson(`${BASE}/api/status-page/heartbeat/${SLUG}`),
]);
const monitors = page.publicGroupList.flatMap((group) => group.monitorList);
if (monitors.length === 0) throw new Error("Sin monitores");
const items = monitors.map((monitor) => {
const history = beats.heartbeatList[monitor.id] ?? [];
const last = history[history.length - 1]; // el latido más reciente
const uptime = beats.uptimeList[`${monitor.id}_24`]; // de 0 a 1, últimas 24 h
const li = document.createElement("li");
li.dataset.status = last ? String(last.status) : "none"; // para darle color con CSS
const state = last ? STATUS[last.status] ?? "Desconocido" : "Sin datos";
const percent = typeof uptime === "number" ? ` · ${(uptime * 100).toFixed(2)} % (24 h)` : "";
li.textContent = `${monitor.name}: ${state}${percent}`;
return li;
});
list.replaceChildren(...items);
widget.hidden = false;
} catch {
// Si la API falla, mejor no enseñar nada que enseñar un estado roto
widget.hidden = true;
}
}
refresh();
setInterval(refresh, 60_000); // refresca cada 60 segundos
Tres detalles importantes:
- Uso
textContent, noinnerHTML. Los nombres vienen de otro servidor: así no se puede inyectar HTML. - Si la API falla, el widget se oculta. Un bloque en rojo que dice “error” en tu portfolio da peor imagen que no enseñar nada.
- Refresco cada 60 segundos, el mismo ritmo al que Uptime Kuma comprueba los servicios. Más a menudo no aporta nada.
Enlázalo desde tu CV y LinkedIn
Además del widget, pon el enlace directo a la página de estado en tu CV y en LinkedIn, con un texto claro como “disponibilidad de mis servicios en tiempo real”. Quien lo abra ve datos vivos, no una promesa.
Problemas frecuentes
| Síntoma | Causa probable | Solución |
|---|---|---|
Monitores con getaddrinfo ENOTFOUND |
Nombre de contenedor de otra red Docker | URL pública o http://IP-NAS:PUERTO |
El contenedor aparece unhealthy pero funciona |
Healthcheck con wget, que la imagen no trae |
["CMD", "extra/healthcheck"] |
| El contenedor se reinicia solo | Falta de memoria (OOM) | Sube mem_limit a 512m |
| No se puede recrear el contenedor | La etiqueta fijada ya no existe | Usa una etiqueta vigente de la rama 1 |
| La página de estado carga sin estilos | Falta alguna ruta pública en @privado |
Revisa /assets/* y /upload/* |
| El widget no aparece | CORS o slug incorrecto | Mira la consola del navegador y prueba la URL de la API |
Conclusión
Uptime Kuma me avisa cuando algo se cae y, además, me da algo que enseñar. La lección más cara fue la del contenedor parado durante semanas: un monitor sin notificaciones no vigila nada. La segunda, que la página pública debe contener solo lo que ya es público.
En el próximo capítulo daré un paso más: automatizar tareas del homelab con n8n, para que el NAS no solo avise, sino que también actúe.
Preguntas frecuentes
¿Por qué no uso la versión 2 de Uptime Kuma?
Porque cambia el esquema de la base de datos. Con los datos de meses en la versión 1, prefiero quedarme en la rama 1 y planear la migración con calma y una copia de seguridad.
¿Es seguro abrir la API de la página de estado a cualquier origen?
Sí, siempre que solo publiques esa API. Son datos públicos y de solo lectura: lo mismo que ve cualquiera en la página de estado. El panel y el resto de la API siguen cerrados.
¿Qué servicios pongo en la página pública?
Solo los que ya son públicos. Nada de paneles de administración, bases de datos ni servicios internos: una página de estado también es un mapa para un atacante.
Fuentes
Proyecto relacionado
Infraestructura Pesoz
Homelab self-hosted en producción 24/7: un NAS Synology con ~30 servicios en Docker tras Caddy + CrowdSec y un portátil con GPU como servidor de IA y observabilidad en k3s. VPN WireGuard, GitOps con Forgejo y estado en vivo con Uptime Kuma.




