Gatus can show a green health-check dashboard within minutes of starting. That doesn’t answer the question I really care about: when a monitored HTTP endpoint fails repeatedly, when does the alert fire? When it returns to HTTP 200, does a recovery notification follow? And if Gatus itself restarts, will the old check history survive?

I tested Gatus v5.37.0 with Docker Compose and a deliberately small HTTP service. The service could be switched from 200 to 503 and back to 200, while another endpoint in the same isolated network recorded Gatus’s custom webhook notifications. Two consecutive 503 samples were followed by a TRIGGERED event; two recovery 200 samples were followed by RESOLVED. An SQLite history record also survived a controlled Gatus container restart.

The target was a synthetic HTTP endpoint, not a production application. This run did not test Slack, email, real external paging, or a multi-host monitoring installation.

Define what Gatus should treat as a failure

Gatus does not need an agent installed in the application. It periodically requests an endpoint and evaluates its health conditions. The version-pinned upstream README covers additional protocols, but I only exercised HTTP status conditions and a custom webhook here.

This is the core config/config.yaml used by the experiment. fixture is the Docker Compose service name for our switchable test endpoint:

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"

The important choices are quite specific:

  • interval: 15s schedules a probe every 15 seconds. It is not a millisecond-accurate deadline.
  • [STATUS] == 200 treats HTTP 200 as healthy. If your service legitimately uses 204, change the condition instead of silently misclassifying it.
  • failure-threshold: 2 waits for two consecutive failures. success-threshold: 2 together with send-on-resolved: true controls the recovery transition and notification.
  • alerting.custom sends an HTTP POST to an in-network test receiver. It does not prove an external messaging service delivered anything.

[ALERT_TRIGGERED_OR_RESOLVED] and [ENDPOINT_NAME] are Gatus template variables. The JSON payload is intentionally simple: I want to see that the receiver actually got both events, not merely that the dashboard changed colour.

A common trap when replacing the test URL with a real endpoint is networking. Inside an ordinary Gatus container, localhost refers to that container’s own network namespace, not automatically the Docker host or another service. Containers on the same Compose network can use service DNS names. For remote targets, DNS, TLS, authentication and reachability all need checking separately; none of those remote-network conditions were reproduced in this run.

Reproduce the 200 → 503 → 200 sequence with two containers

Rather than disconnect a real business service, I used a small Python fixture. GET /health reads a state file and returns either 200 or 503. POST /alert records Gatus’s webhook payload in a local newline-delimited JSON file. This isolates the monitoring chain from an application’s own failures.

Create a disposable, dedicated test directory containing compose.yaml, config/config.yaml, fixture/server.py, and digest-lock.env. Here is the inspected fixture logic, presented as a copyable example:

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()

The Compose setup preserves the network and resource boundaries from the experiment. internal: true limits the fixture/Gatus network; the fixture has no published host port, while the Gatus dashboard is bound to a dynamic port on 127.0.0.1. Memory and CPU values are configured limits, not measured utilisation.

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:

I did not rely on a drifting latest tag. These are the Linux/amd64 image digests recorded in digest-lock.env for the run:

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

The test host ran Linux/amd64, Docker Engine 29.9.0 and Docker Compose v5.6.0. Do not copy these architecture-specific digests onto ARM without checking the image manifests. The original experiment also inspected the resolved image platforms.

For a reader reproducing the experiment, these commands are a practical sequence. They are not presented as a verbatim transcript of what the original harness executed:

$ 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

Once started, query the Gatus status API from the fixture container. Wait until it includes two new 200 samples before introducing the fault:

$ 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'

Wait for two new consecutive 503 samples to appear in the API. Then restore the fixture and inspect the webhook receipts:

$ 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

Don’t use a blind sleep 30 and assume a stage succeeded. The original harness polled the API and looked for fresh samples, which is the safer approach when probe scheduling and startup readiness can vary.

What the run actually recorded

On October 9, 2026, the Gatus status API recorded seven observations. Times below are UTC; milliseconds come from the verified experiment summary.

Time (UTC)StatusObservation
14:39:41.8440 (connection refused)Startup transient, not an HTTP 0 response
14:39:56.896200Healthy check 1
14:40:11.896200Healthy check 2
14:40:26.853503Injected failure 1
14:40:41.895503Injected failure 2
14:40:56.895200Recovery check 1
14:41:11.853200Recovery check 2

In the same controlled run, the local webhook receiver wrote these events:

EventReceived (UTC)
TRIGGERED14:40:41.907
RESOLVED14:41:11.861

The second 503 sample and the trigger receipt are only milliseconds apart. The second recovery 200 and the resolved receipt line up just as closely. The useful evidence is not a green screenshot; it is the status timeline paired with events received by an actual test handler.

But don’t convert this into a promise of “production alerts arrive within 30 seconds”. Probe timing, request duration, scheduling, network conditions and the destination service all matter. These receipts came from a synthetic fixture on an isolated Docker network. They do not establish delivery through external notification channels or any production SLA.

The failure that appeared before fault injection

One observation was unexpected: before I switched the fixture to “down”, the very first Gatus poll reported status 0, with a connection refusal. Two successful 200 checks followed. It would be inaccurate to call this an “HTTP 0” response; no valid HTTP status was received.

There are two different meanings of “started” here. Docker Compose up -d --wait waits for services to be running or to pass any declared health checks. This particular Compose file did not define healthchecks for the two services. Therefore, a successful up does not prove /health was reachable on Gatus’s first attempt. The application may still be initialising when the monitoring container is already running.

If you see “container Up, first check failed”, first ask whether the failure persists instead of immediately increasing thresholds to conceal it. My troubleshooting order is:

  1. Inspect docker compose ... ps and logs gatus to check process startup and configuration loading.
  2. Request the target /health from the same container network. Separate application readiness from DNS, routing, port and real HTTP 503 failures.
  3. Read consecutive samples in the Gatus status API. Keep a startup transient distinct from a fault you deliberately injected later.
  4. If failed checks are recognised but alerts don’t arrive, examine alerting.custom, receiver routing and the webhook event file.

This is an actionable diagnosis derived from the observation, not a claim that all four failure classes happened on the test machine. The run actually observed one initial refusal, followed by a healthy/failure/recovery sequence and matching webhook events.

For real applications, define what /health should mean: startup in progress, dependency missing, degraded functionality or genuine unavailability. If your application returns 200 for every case, a condition such as [STATUS] == 200 cannot tell them apart.

SQLite history survived a restart; deleting the volume is different

Gatus stored its check history in /data/gatus.db, backed by the gatus_data named volume. The experiment captured the exact timestamp of a previously saved status, restarted only the Gatus container, and queried the status API again. The old timestamp was still present; the harness reported:

SQLITE_RESTART_HISTORY_PRESERVED

That establishes a narrow fact: restarting this container while keeping its data volume did not erase that status history. It does not establish a tested backup procedure, disk-failure recovery or disaster recovery capability.

At the end, the disposable Compose project was shut down and its project-scoped resources removed. For an experiment you genuinely intend to discard, the commands are:

$ 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"

Be careful: down --volumes also deletes this project’s named volume and its SQLite history. Do not run it as part of routine maintenance for a long-lived Gatus installation. Keep the volume and separately design and test database backup and restore.

Several questions remain open before I’d rely on this setup for production monitoring. Who detects that Gatus itself has stopped? What happens when the external notification destination is down? How do simultaneous endpoint failures behave? Can a cross-host network problem be distinguished from an unhealthy application? This one experiment does not answer them.

What it does show is concrete: with Gatus v5.37.0, two isolated containers and a controlled HTTP endpoint, the configured failure threshold, recovery webhook and SQLite restart history were all observable. Before treating a real service as monitored, I’d separately test the service’s health semantics and the notification path under realistic failure conditions.