Gatus se pone en marcha y el panel muestra una comprobación de salud en verde. Parece que la supervisión ya funciona, pero aún falta una prueba bastante más útil: ¿cuándo se dispara realmente la alerta si un endpoint HTTP empieza a fallar? ¿Llega una notificación de recuperación cuando vuelve a responder 200? ¿Se conserva el historial si reinicio el contenedor de Gatus?

Para comprobarlo ejecuté Gatus v5.37.0 con Docker Compose y un servicio HTTP deliberadamente pequeño. Podía cambiar su respuesta de 200 a 503 y otra vez a 200. Un receptor Webhook en la misma red aislada guardaba los eventos. Después de dos comprobaciones 503 consecutivas recibí TRIGGERED, y después de dos respuestas 200 de recuperación recibí RESOLVED. Además, un registro anterior de SQLite continuó disponible después de reiniciar únicamente el contenedor de Gatus.

Fue una prueba con un endpoint sintético, no con una aplicación en producción. No probé Slack, correo, entrega de avisos a servicios externos, supervisión entre hosts ni compromisos de disponibilidad.

Antes de probar, definir qué considera Gatus un fallo

Gatus consulta periódicamente una URL y evalúa la respuesta con expresiones de condición. No hace falta instalar un agente dentro de la aplicación supervisada. El README oficial fijado a un commit documenta otros protocolos; aquí solo he verificado condiciones sobre el estado HTTP y un Webhook personalizado.

Esta es la parte principal de config/config.yaml utilizada en el experimento. fixture es el nombre del servicio de Compose cuyo estado podemos modificar:

storage:
  type: sqlite
  path: /data/gatus.db

alerting:
  custom:
    url: "http://fixture:8000/alert"
    method: POST
    headers:
      Content-Type: application/json
    body: |
      {"event":"[ALERT_TRIGGERED_OR_RESOLVED]","endpoint":"[ENDPOINT_NAME]"}

endpoints:
  - name: controlled-fixture
    group: gatus-v34
    url: "http://fixture:8000/health"
    interval: 15s
    conditions:
      - "[STATUS] == 200"
    alerts:
      - type: custom
        failure-threshold: 2
        success-threshold: 2
        send-on-resolved: true
        description: "Only a disposable synthetic fixture; no real infrastructure"

Los ajustes decisivos son estos:

  • interval: 15s: programa una comprobación aproximadamente cada 15 segundos. No es una garantía de ejecución exacta al milisegundo.
  • [STATUS] == 200: solo el estado HTTP 200 se interpreta como saludable. Si tu API utiliza 204 como respuesta válida, debes modificar la condición.
  • failure-threshold: 2: exige dos fallos consecutivos para activar la alerta. success-threshold: 2 y send-on-resolved: true configuran la detección de recuperación y su aviso.
  • alerting.custom: hace un POST con JSON a nuestro receptor de pruebas. No demuestra que un sistema de notificaciones externo haya entregado el mensaje.

[ALERT_TRIGGERED_OR_RESOLVED] y [ENDPOINT_NAME] son variables de plantilla de Gatus. Elegí un JSON sencillo porque quería confirmar que el receptor había recibido los dos eventos, no limitarme a observar que la interfaz cambiaba de color.

Al sustituir la URL de prueba por una aplicación real hay que entender bien la red de contenedores. En un contenedor Gatus con red aislada convencional, localhost hace referencia al espacio de nombres de red del propio contenedor, no automáticamente al host Docker ni a otro servicio. En la misma red de Compose se puede utilizar el nombre DNS del servicio; para otros servidores hay que comprobar también DNS, TLS, autenticación y rutas. Esta prueba no abarcó destinos remotos.

Reproducir 200 → 503 → 200 con dos contenedores

No quise provocar una caída de una aplicación real. Preparé un pequeño servicio Python: GET /health lee un archivo de estado y responde 200 o 503; POST /alert guarda los mensajes Webhook de Gatus en un registro JSON por líneas. Así el camino de supervisión y aviso queda separado de las averías propias de una aplicación compleja.

En un directorio de laboratorio independiente que puedas destruir por completo, prepara cuatro archivos: compose.yaml, config/config.yaml, fixture/server.py y digest-lock.env. Este es un ejemplo copiable basado en la lógica del fixture inspeccionado:

import datetime
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlsplit

STATE = Path("/state")
STATE.mkdir(parents=True, exist_ok=True)
MODE = STATE / "mode"
EVENTS = STATE / "alerts.ndjson"
MAX_BODY = 16 * 1024

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if urlsplit(self.path).path != "/health":
            self.send_error(404)
            return
        healthy = (MODE.read_text().strip() if MODE.exists() else "up") == "up"
        body = b'{"status":"UP"}' if healthy else b'{"status":"DOWN"}'
        self.send_response(200 if healthy else 503)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_POST(self):
        if urlsplit(self.path).path != "/alert":
            self.send_error(404)
            return
        try:
            size = int(self.headers.get("Content-Length", "-1"))
        except ValueError:
            size = -1
        if not (0 <= size <= MAX_BODY):
            self.send_error(413)
            return
        try:
            payload = json.loads(self.rfile.read(size))
        except (UnicodeError, json.JSONDecodeError):
            self.send_error(400)
            return
        record = {
            "at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
            "payload": payload,
        }
        with EVENTS.open("a", encoding="utf-8") as out:
            out.write(json.dumps(record, ensure_ascii=False) + "\n")
        self.send_response(204)
        self.end_headers()

    def log_message(self, fmt, *args):
        pass

ThreadingHTTPServer(("0.0.0.0", 8000), Handler).serve_forever()

El archivo Compose mantiene el aislamiento y los límites del ensayo. internal: true limita la red de ambos servicios; el fixture no publica puertos al host y el panel de Gatus solo se vincula a un puerto dinámico de 127.0.0.1. Los valores de CPU y memoria son límites configurados, no mediciones reales de consumo.

services:
  fixture:
    image: ${FIXTURE_IMAGE:?Set a pinned fixture image}
    command: ["python", "/app/server.py"]
    read_only: true
    cap_drop: ["ALL"]
    security_opt: ["no-new-privileges:true"]
    tmpfs: ["/tmp"]
    mem_limit: 96m
    cpus: "0.25"
    pids_limit: 64
    volumes:
      - ./fixture/server.py:/app/server.py:ro
      - fixture_state:/state
    networks: ["isolated"]
    restart: "no"

  gatus:
    image: ${GATUS_IMAGE:?Set a pinned Gatus image}
    environment:
      GATUS_CONFIG_PATH: /config/config.yaml
    read_only: true
    cap_drop: ["ALL"]
    security_opt: ["no-new-privileges:true"]
    tmpfs: ["/tmp"]
    mem_limit: 256m
    cpus: "0.50"
    pids_limit: 128
    volumes:
      - ./config/config.yaml:/config/config.yaml:ro
      - gatus_data:/data
    ports:
      - "127.0.0.1::8080"
    networks: ["isolated"]
    depends_on: ["fixture"]
    restart: "no"

networks:
  isolated:
    internal: true

volumes:
  fixture_state:
  gatus_data:

Tampoco utilicé la etiqueta cambiante latest. Estos fueron los digests de imágenes Linux/amd64 registrados en digest-lock.env:

GATUS_IMAGE=ghcr.io/twin/gatus@sha256:094eb186e55235db367e90e9d56140e5897b7044c41de23cde4cc18e6da1242e
FIXTURE_IMAGE=python@sha256:f3ebba2ace255c93267a0278da88c7f1044432991abc4e6ad20d22e34dd0f8ee

El host de prueba ejecutaba Linux/amd64, Docker Engine 29.9.0 y Docker Compose v5.6.0. El procedimiento original también inspeccionó la plataforma efectiva de las imágenes. No reutilices directamente estos digests en una máquina ARM sin comprobar antes los manifests correspondientes.

Para repetir el ensayo manualmente, esta secuencia es práctica. No afirmo que el experimento original ejecutara literalmente cada comando con esta redacción: son instrucciones de reproducción preparadas para el lector.

$ docker compose --env-file digest-lock.env -p gatus-lab config --quiet
$ docker compose --env-file digest-lock.env -p gatus-lab up -d --wait --wait-timeout 60
$ docker compose --env-file digest-lock.env -p gatus-lab ps
$ docker compose --env-file digest-lock.env -p gatus-lab logs --tail 80 gatus

Una vez iniciado, consulta la API de estados de Gatus desde el contenedor fixture. Espera a que haya dos muestras nuevas con código 200 antes de inyectar el fallo:

$ docker compose --env-file digest-lock.env -p gatus-lab exec -T fixture python -c 'import urllib.request; print(urllib.request.urlopen("http://gatus:8080/api/v1/endpoints/statuses").read().decode())'
$ docker compose --env-file digest-lock.env -p gatus-lab exec -T fixture sh -c 'printf down >/state/mode'

Confirma que la API registra dos nuevas respuestas 503 consecutivas. Solo entonces restaura el estado saludable y revisa los avisos recibidos:

$ docker compose --env-file digest-lock.env -p gatus-lab exec -T fixture sh -c 'printf up >/state/mode'
$ docker compose --env-file digest-lock.env -p gatus-lab exec -T fixture cat /state/alerts.ndjson

No basta con ejecutar sleep 30 y suponer que cada fase ha terminado. El código de comprobación utilizado originalmente consultaba la API repetidamente y avanzaba cuando encontraba muestras realmente nuevas. Es importante, sobre todo porque el tiempo de inicialización de los servicios puede variar.

Lo que quedó registrado: alerta tras el segundo 503 y cierre tras el segundo 200

En la ejecución del 9 de octubre de 2026, la API de Gatus conservó siete observaciones. Todas las horas están en UTC y los milisegundos proceden del resumen verificado del experimento.

Hora (UTC)EstadoQué ocurrió
14:39:41.8440 (conexión rechazada)Fallo transitorio de arranque, no HTTP 0
14:39:56.896200Comprobación saludable 1
14:40:11.896200Comprobación saludable 2
14:40:26.853503Fallo simulado 1
14:40:41.895503Fallo simulado 2
14:40:56.895200Recuperación 1
14:41:11.853200Recuperación 2

Durante la misma ejecución, el receptor Webhook local guardó lo siguiente:

EventoRecepción (UTC)
TRIGGERED14:40:41.907
RESOLVED14:41:11.861

La segunda muestra 503 y la recepción de TRIGGERED están separadas por apenas unos milisegundos. Con la recuperación ocurrió lo mismo: el segundo 200 fue seguido por RESOLVED. La línea temporal junto a los mensajes recibidos ofrece una prueba más útil que una simple captura de un panel verde.

Pero no sería correcto convertir esto en «un fallo de producción siempre genera una alerta en menos de 30 segundos». El intervalo entre sondeos, su duración, la planificación, la red y la disponibilidad del destino también influyen. Aquí solo quedó probada la entrega del Webhook local en una red Docker aislada ante un fallo sintético, no la entrega externa ni un SLA.

El problema real que apareció antes de introducir el fallo

La primera observación, incluso antes de cambiar el fixture a «down», fue 0 con una conexión rechazada. Después aparecieron dos respuestas 200 correctas. No significa que el servicio haya devuelto «HTTP 0»: significa que no se obtuvo ninguna respuesta HTTP válida en aquel primer intento.

Conviene distinguir «el contenedor está ejecutándose» de «el servicio ya está preparado para contestar su endpoint de salud».

Docker Compose up -d --wait espera a que los contenedores estén en marcha o superen las comprobaciones de salud declaradas. Pero esta configuración no define healthcheck para ninguno de los dos servicios. Por tanto, que el comando termine correctamente no demuestra que /health estuviera disponible en el primer sondeo de Gatus. El servicio objetivo todavía podría estar inicializándose.

Si ves «Container Up, primera comprobación fallida», antes de subir el umbral para ocultar el problema seguiría este orden:

  1. Revisar docker compose ... ps y logs gatus para confirmar el arranque del proceso y la carga de la configuración.
  2. Consultar /health desde la misma red de contenedores que utiliza Gatus. Distinguir falta de preparación, DNS, puerto o un HTTP 503 auténtico.
  3. Leer varias muestras consecutivas de la API de estados y separar el fallo transitorio de arranque de los fallos 503 introducidos deliberadamente después.
  4. Si Gatus detecta el fallo pero no llega ningún aviso, revisar alerting.custom, la ruta al receptor y el archivo de eventos Webhook.

Es un método de diagnóstico elaborado a partir de lo observado. No quiere decir que las cuatro clases de averías ocurrieran en esta máquina de ensayo. Lo que apareció fue una conexión rechazada durante el arranque; después, las comprobaciones 200 → 503 → 200 y los mensajes coincidieron con la configuración.

En una aplicación real también hay que definir el significado de /health. ¿Qué devuelve durante el arranque, cuando falla una dependencia, cuando una función está degradada o cuando el servicio realmente no puede operar? Si responde 200 ante todo, la condición [STATUS] == 200 tampoco permite a Gatus distinguir esos casos.

SQLite conservó el historial tras reiniciar; borrar el volumen es diferente

Gatus guardó el historial en /data/gatus.db, respaldado por el volumen con nombre gatus_data. El ensayo obtuvo la marca de tiempo exacta de un resultado ya almacenado, reinició únicamente el contenedor Gatus y volvió a consultar la misma API. La entrada anterior seguía allí y el procedimiento indicó:

SQLITE_RESTART_HISTORY_PRESERVED

Esto demuestra únicamente que el reinicio de ese contenedor manteniendo su volumen de datos no borró aquel historial. No demuestra que se haya probado una estrategia completa de backups, recuperación tras pérdida de disco ni recuperación ante desastres.

Al terminar se limpiaron solo los recursos del proyecto Compose desechable y se comprobó que no quedaran contenedores con su etiqueta de proyecto. Si de verdad quieres destruir todo ese laboratorio, puedes utilizar:

$ docker compose --env-file digest-lock.env -p gatus-lab down --volumes --remove-orphans
$ docker ps -aq --filter "label=com.docker.compose.project=gatus-lab"

Atención: down --volumes también borra el volumen con nombre y el historial SQLite de este proyecto. No lo uses como operación habitual de mantenimiento de un Gatus que debas conservar. Mantén el volumen y diseña y ensaya aparte la copia de seguridad y la restauración de la base de datos.

Antes de depender de esta solución para supervisión de producción, todavía probaría otros escenarios. ¿Quién detecta la caída del propio Gatus? ¿Qué pasa si el destino externo de los avisos no responde? ¿Cómo se comportan varios endpoints fallando simultáneamente? ¿Se puede distinguir un fallo de red entre servidores de una avería de la aplicación? Un único ensayo controlado no permite responder con certeza.

El resultado confirmado es bastante concreto: con Gatus v5.37.0, dos contenedores aislados y un endpoint HTTP controlable, observé el umbral de errores consecutivos, la notificación de recuperación y la conservación del historial SQLite tras un reinicio. Antes de considerar supervisado un servicio de verdad, probaría por separado el significado de su endpoint de salud y toda la ruta de notificaciones con fallos realistas.