Gatus Docker Compose Test: When a Service Returns 503, Do Alerts Fire and Resolve?
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: 15sschedules a probe every 15 seconds. It is not a millisecond-accurate deadline.[STATUS] == 200treats HTTP 200 as healthy. If your service legitimately uses 204, change the condition instead of silently misclassifying it.failure-threshold: 2waits for two consecutive failures.success-threshold: 2together withsend-on-resolved: truecontrols the recovery transition and notification.alerting.customsends 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) | Status | Observation |
|---|---|---|
| 14:39:41.844 | 0 (connection refused) | Startup transient, not an HTTP 0 response |
| 14:39:56.896 | 200 | Healthy check 1 |
| 14:40:11.896 | 200 | Healthy check 2 |
| 14:40:26.853 | 503 | Injected failure 1 |
| 14:40:41.895 | 503 | Injected failure 2 |
| 14:40:56.895 | 200 | Recovery check 1 |
| 14:41:11.853 | 200 | Recovery check 2 |
In the same controlled run, the local webhook receiver wrote these events:
| Event | Received (UTC) |
|---|---|
TRIGGERED | 14:40:41.907 |
RESOLVED | 14: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:
- Inspect
docker compose ... psandlogs gatusto check process startup and configuration loading. - Request the target
/healthfrom the same container network. Separate application readiness from DNS, routing, port and real HTTP 503 failures. - Read consecutive samples in the Gatus status API. Keep a startup transient distinct from a fault you deliberately injected later.
- 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.