Overview
Most homelabs find out a service is down because a family member complains. Heavyweight monitoring stacks (Prometheus, Zabbix) answer deep questions about CPU and disk, but they are overkill for the simplest one: is the thing actually reachable and answering correctly right now?
Gatus is a small Go service that answers exactly that. You describe endpoints in a single YAML file, each with a list of conditions such as [STATUS] == 200 or [RESPONSE_TIME] < 800. Gatus probes them on an interval, renders a clean status page, keeps history in SQLite, and fires alerts when conditions fail repeatedly. Because the whole setup is one file, it lives happily in Git and is easy to review.
By the end of this project you will have:
- A Gatus container behind a persistent SQLite volume
- HTTP checks that validate status code, latency, and response body content
- TCP, DNS, and TLS certificate-expiry checks
- Grouped endpoints rendered as collapsible sections on a public-looking status page
- Discord alerts with failure and recovery thresholds so you are not paged by a single blip
- A repeatable test routine that proves the alerts actually fire
Architecture and Design
+---------------------------+
config.yaml -->| Gatus |--> status page :8080
(in Git) | scheduler + evaluator |
+--+---------+---------+----+
| | |
HTTP/S probes TCP/DNS TLS expiry
| | |
+-----v---+ +---v----+ +--v-------+
| web apps| | DB/SSH | | resolvers|
+---------+ +--------+ +----------+
|
+--> SQLite (/data/data.db) --> history survives restarts
+--> Discord webhook --> fail / resolved messagesDesign decisions worth making up front:
- Probe from where users stand. A check run from the same Docker host can pass while the reverse proxy is broken. Probe the public or LAN hostname, not the container name, for anything users reach through a proxy. Add separate internal checks only to tell "proxy broken" from "app broken".
- Assert on content, not just status. A 200 from an error page behind a CDN is still an outage. Use
[BODY]conditions on endpoints that expose a health JSON. - Alert on trends, not blips. Require several consecutive failures before alerting, and send a resolved notice so the channel closes its own loop.
- Keep secrets out of the file. Gatus expands
${VAR}environment variables in the config, so the webhook URL stays in an env file andconfig.yamlcan be committed.
Step-by-Step Build
1. Project layout
mkdir -p ~/gatus/{config,data} && cd ~/gatus
touch .env
chmod 600 .envPut the Discord webhook in .env:
# ~/gatus/.env
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/<id>/<token>2. Docker Compose
Pin the image to a specific release tag rather than latest so upgrades are deliberate. Check the project's releases page for the current tag.
# ~/gatus/docker-compose.yml
services:
gatus:
image: twinproduction/gatus:v5 # pin to an exact release tag in production
container_name: gatus
restart: unless-stopped
ports:
- "8080:8080"
env_file: .env
volumes:
- ./config:/config:ro
- ./data:/data
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALLThe container needs no capabilities for HTTP, TCP, DNS, and TLS checks, so everything is dropped. If you later add ICMP checks, those require NET_RAW (and the endpoint must be configured accordingly).
3. Base configuration with storage and UI
# ~/gatus/config/config.yaml
storage:
type: sqlite
path: /data/data.db
web:
port: 8080
ui:
title: "Homelab Status"
header: "Homelab Status"
description: "Live health of internal services"
alerting:
discord:
webhook-url: "${DISCORD_WEBHOOK_URL}"
default-alert:
failure-threshold: 3
success-threshold: 2
send-on-resolved: true
description: "health check failed"With these defaults, an endpoint must fail three consecutive checks before a message is sent, and pass twice in a row before the resolved message goes out. Any endpoint that lists a discord alert inherits those thresholds.
4. HTTP endpoints with real conditions
Append to config.yaml:
endpoints:
- name: Wiki
group: Apps
url: "https://wiki.example.lan/"
interval: 60s
conditions:
- "[STATUS] == 200"
- "[RESPONSE_TIME] < 1500"
- "[CERTIFICATE_EXPIRATION] > 336h" # warn if the cert has under 14 days left
alerts:
- type: discord
- name: Git API
group: Apps
url: "https://git.example.lan/api/healthz"
interval: 60s
conditions:
- "[STATUS] == 200"
- "[BODY].status == pass"
alerts:
- type: discordThe [BODY].status placeholder walks the JSON response. Use it only against endpoints that return JSON; for HTML pages use [BODY] == pat(*Welcome*) pattern matching instead.
5. TCP and DNS checks
Services without an HTTP surface (databases, SSH, resolvers) still deserve checks:
- name: Postgres
group: Infrastructure
url: "tcp://10.0.0.20:5432"
interval: 30s
conditions:
- "[CONNECTED] == true"
alerts:
- type: discord
- name: SSH bastion
group: Infrastructure
url: "tcp://10.0.0.5:22"
interval: 60s
conditions:
- "[CONNECTED] == true"
- name: Internal DNS resolves
group: Infrastructure
url: "10.0.0.2" # the resolver to test
interval: 30s
dns:
query-name: "wiki.example.lan"
query-type: "A"
conditions:
- "[DNS_RCODE] == NOERROR"
- "[BODY] == 10.0.0.10"
alerts:
- type: discordThe DNS check is the most valuable of the three: it fails if the resolver is down or returns the wrong answer, which catches stale records after a migration.
6. External reachability and a heartbeat
Add one endpoint that proves your own internet path, so you can tell "my network is down" from "everything is down":
- name: Internet (Cloudflare)
group: Network
url: "https://1.1.1.1/"
interval: 60s
client:
insecure: true
conditions:
- "[STATUS] < 500"The client.insecure flag is acceptable here because the check only cares about reachability, not identity. Do not copy it to endpoints where certificate validity matters.
7. Start it
docker compose up -d
docker compose logs -f gatusOpen http://<host>:8080. Within a minute each endpoint shows a result bar and response time graph.
Testing
Never trust an alerting pipeline you have not seen fire.
-
Validate config first. Gatus logs a clear error and refuses to load a malformed file. After every edit run
docker compose restart gatus && docker compose logs --tail 30 gatusand confirm there are no parse errors. -
Force a failure. Add a throwaway endpoint that cannot succeed and a one-failure alert:
- name: Alert test group: Test url: "http://127.0.0.1:9/" interval: 10s conditions: - "[CONNECTED] == true" alerts: - type: discord failure-threshold: 1 success-threshold: 1 description: "alert pipeline test"Expect a failure message in Discord within about ten seconds.
-
Force recovery. Change the URL to a reachable service, restart, and confirm the resolved message arrives. Then delete the test endpoint.
-
Real outage drill. Stop one real container (
docker stop <app>), wait for the threshold to pass, confirm the alert and the red bar on the page, then start it again and confirm recovery. -
Persistence check. Run
docker compose down && docker compose up -d. History should still be on the page, proving the SQLite volume works. -
Certificate condition. Temporarily set
[CERTIFICATE_EXPIRATION] > 8760hon one endpoint; it should fail, confirming the expiry check is evaluated against a real cert.
Deployment
- Put it behind your reverse proxy with TLS, and decide who can view it. A status page is useful to household members, but it also maps your internal services, so keep it LAN-only or behind SSO rather than exposing it publicly.
- Run it on a different host than the things it watches if you can. A status page that dies with the server it monitors tells you nothing. A small VPS or a Raspberry Pi works well; a second Gatus instance can even watch the first.
- Keep the config in Git. Every threshold change then has a commit message explaining why.
- Add a dead man's switch. Gatus can only alert if it is running. Point a free heartbeat service at an outbound ping, or have a second instance check the first, so you hear about Gatus itself failing.
- Back up
/data. History is nice to have; the config in Git is what actually matters for recovery. - Upgrade deliberately. Bump the pinned image tag, restart, and re-run the persistence and alert tests above.
Extensions and Next Steps
- Metrics: enable the Prometheus metrics endpoint with
metrics: trueand graph check results and response times in Grafana alongside your other dashboards. - More alert providers: configure Slack, Telegram, ntfy, Gotify, or PagerDuty for severity-based routing, for example PagerDuty for the router and Discord for everything else.
- Per-endpoint maintenance windows: use the
maintenance-windowssetting to silence expected downtime during patching. - Deeper checks: test gRPC, WebSocket, STARTTLS mail ports, or SSH banner responses to cover services beyond HTTP.
- Generate config from inventory: template endpoints from your Compose labels or a CSV so adding a service automatically adds a check.
- Public incident page: publish a trimmed copy of the page for users, with only the services they care about.
A status page will not replace metrics, logs, or tracing, but it answers the first question in any outage in seconds. For an hour of work and a single YAML file, it is one of the highest return-on-effort additions to a homelab.