PROJECTBeginner

Gatus: A Config-as-Code Status Page with Health Checks and Alerting for Your Homelab

Build a self-hosted status page that probes HTTP, TCP, DNS, and TLS endpoints with condition-based checks, persists history in SQLite, and alerts to Discord.

Dylan H.

Projects

October 28, 2026
7 min read
1-2 hours
Gatus: A Config-as-Code Status Page with Health Checks and Alerting for Your Homelab

Tools & Technologies

GatusDocker ComposeSQLiteDiscord Webhooks

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 messages

Design 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 and config.yaml can be committed.

Step-by-Step Build

1. Project layout

mkdir -p ~/gatus/{config,data} && cd ~/gatus
touch .env
chmod 600 .env

Put 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:
      - ALL

The 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: discord

The [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: discord

The 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 gatus

Open 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.

  1. 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 gatus and confirm there are no parse errors.

  2. 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.

  3. Force recovery. Change the URL to a reachable service, restart, and confirm the resolved message arrives. Then delete the test endpoint.

  4. 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.

  5. Persistence check. Run docker compose down && docker compose up -d. History should still be on the page, proving the SQLite volume works.

  6. Certificate condition. Temporarily set [CERTIFICATE_EXPIRATION] > 8760h on 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: true and 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-windows setting 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.

References