Gatus doesn’t publish binaries on its GitHub releases page, so the tarball download in older Gatus guides fails with a 404. That includes the first version of this one. You run Gatus monitoring from the container image now, and the YAML you feed it got stricter too: a security block that older releases accepted can crash Gatus at startup.
We run Gatus with Docker Compose and build a config you can copy, with HTTP, TCP, DNS TXT, gRPC and SSH key checks. After that come live alerts through ntfy, Slack and Zulip config, external endpoints for cron jobs, the status page, then basic auth and TLS. Every command below was run in October 2026 against Gatus v5.37.0 on an Ubuntu 26.04 host with the Docker CLI and Compose plugin, and every output block comes from that run.
Prerequisites
Gatus is a single Go binary, so the host can be small. Our lab instance held about 35 MB of resident memory while it watched seven endpoints, which means a 1 GB VPS has room for it and the services it checks. Before you start, have these ready:
- A Linux server with Docker Engine and the Compose plugin
- Outbound access to the endpoints you plan to check
- TCP 8080 free for the dashboard (9443 later, if you turn on TLS)
- A domain name and certificate only for the HTTPS step at the end
If Docker is not on the box yet, follow the Docker CE install steps for Ubuntu first, and the Docker Compose guide if docker compose version doesn’t answer.
Run Gatus with Docker Compose
Pin the image to the current release instead of latest, because a config that works today can be rejected by a later release, and you want upgrades to happen when you choose. We ask the GitHub API for the newest tag and keep it in a shell variable.
export GATUS_VERSION=$(curl -s https://api.github.com/repos/TwiN/gatus/releases/latest | grep '"tag_name"' | cut -d '"' -f 4)
echo "$GATUS_VERSION"
The tag comes back with its leading v, which matches the Docker Hub tag names:
v5.37.0
Create a project directory with a config folder for the YAML and a data folder for the SQLite database. You will run every later command from inside it.
mkdir -p ~/gatus/config ~/gatus/data && cd ~/gatus
Secrets stay out of the YAML. Gatus expands ${VAR} references in its config from its own environment, so the ntfy topic and a token for the external endpoint go into an env file that Compose hands to the container:
echo "NTFY_TOPIC=gatus-alerts" > gatus.env
echo "BACKUP_PUSH_TOKEN=$(openssl rand -hex 24)" >> gatus.env
Now create the Compose file.
vim compose.yaml
Paste the following. It runs Gatus next to a self-hosted ntfy server, which gives you a working alert channel without signing up for anything.
services:
gatus:
image: twinproduction/gatus:${GATUS_VERSION:-stable}
container_name: gatus
restart: unless-stopped
network_mode: host
env_file: gatus.env
volumes:
- ./config:/config:ro
- ./data:/data
depends_on:
- ntfy
ntfy:
image: binwiederhier/ntfy:latest
container_name: ntfy
command: serve --listen-http 127.0.0.1:8090
restart: unless-stopped
network_mode: host
Both services use host networking, and ntfy listens on loopback only, so nobody outside the box can read or post to your alert topic. Host networking lets Gatus reach anything bound to the host’s 127.0.0.1, such as a database or an admin port, which a bridged container can’t see, and it removes NAT from the ping and TCP timings. The image line falls back to the stable tag when GATUS_VERSION is empty, so export the variable again in any new shell before you run Compose, or the next up quietly switches you to a different tag.
Write the Gatus config file
Gatus reads /config/config.yaml inside the container, which is ~/gatus/config/config.yaml on the host. Open it in your editor.
vim config/config.yaml
This is the exact file our lab ran. It stores results in SQLite, sends alerts to ntfy, checks a public website and a JSON API, and watches ntfy itself over HTTP and TCP. It also queries a DNS TXT record and includes one endpoint, billing-api, that is meant to fail until something listens on port 8091.
storage:
type: sqlite
path: /data/data.db
ui:
title: "Service status"
header: "Service status"
alerting:
ntfy:
url: "http://127.0.0.1:8090"
topic: "${NTFY_TOPIC}"
priority: 4
default-alert:
failure-threshold: 2
success-threshold: 2
send-on-resolved: true
description: "health check failed"
endpoints:
- name: website
group: public
url: "https://example.com"
interval: 1m
conditions:
- "[STATUS] == 200"
- "[RESPONSE_TIME] < 1500"
- "[CERTIFICATE_EXPIRATION] > 720h"
- name: github-api
group: public
url: "https://api.github.com/repos/TwiN/gatus"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].archived == false"
- "[BODY].default_branch == master"
ui:
resolve-successful-conditions: true
- name: ntfy-health
group: internal
url: "http://127.0.0.1:8090/v1/health"
interval: 30s
conditions:
- "[STATUS] == 200"
- "[BODY].healthy == true"
- name: ntfy-port
group: internal
url: "tcp://127.0.0.1:8090"
interval: 30s
conditions:
- "[CONNECTED] == true"
- name: spf-record
group: dns
url: "1.1.1.1"
interval: 5m
dns:
query-name: "example.com"
query-type: "TXT"
conditions:
- "[DNS_RCODE] == NOERROR"
- "[BODY] == pat(*v=spf1*)"
- name: billing-api
group: internal
url: "http://127.0.0.1:8091/health"
interval: 30s
conditions:
- "[STATUS] == 200"
ui:
hide-errors: true
alerts:
- type: ntfy
external-endpoints:
- name: nightly-backup
group: jobs
token: "${BACKUP_PUSH_TOKEN}"
heartbeat:
interval: 26h
alerts:
- type: ntfy
Every condition on an endpoint must pass for the check to count as healthy. [RESPONSE_TIME] is in milliseconds, [CERTIFICATE_EXPIRATION] > 720h fails 30 days before the TLS certificate runs out, and [BODY].archived walks into the JSON response. Swap the targets for your own services when you adapt it, but keep a deliberately broken one like billing-api for the first run so you can watch an alert fire and resolve before you trust it with real outages.
A few keys in that file are newer than most Gatus examples you’ll find, and each one changes what the status page shows:
dns.query-type: "TXT", for SPF, DKIM and domain verification recordsui.resolve-successful-conditions, which prints the real value next to passing conditionsui.hide-errors, which keeps raw error strings off a public pageheartbeat.intervalon an external endpoint, which marks it failed when a push is overdue
These ui keys sit under each endpoint, not under the global ui block. The global block holds page-level settings such as the title, header, logo, buttons, custom CSS, dark mode and the default sort and filter. Until the auth step near the end, the dashboard on port 8080 is open to anyone who can reach the host, so keep it firewalled while you build the config.
Start Gatus and verify the checks
Bring the stack up. Compose pulls both images on the first run, so give it a minute.
docker compose up -d
Gatus validates the whole file before it starts listening, so the first log lines tell you whether the config was accepted. You read them through docker logs.
docker logs gatus 2>&1 | head -20
Look for the provider list, the endpoint count and the Listening line. This capture is from a restart a few minutes into the lab, with the Fiber web server banner that follows the Listening line trimmed out. Here ntfy is the only configured provider and the other 40 built-in ones are skipped, six endpoints and one external endpoint passed validation, and billing-api failed its first check exactly as planned:
2026/10/27 16:29:48 [main.configureLogging] Log Level is set to INFO
2026/10/27 16:29:48 [config.LoadConfiguration] Reading configuration from configFile=config/config.yaml
2026/10/27 16:29:48 [config.ValidateAlertingConfig] configuredProviders=[ntfy]; ignoredProviders=[aws-ses clickup custom datadog discord email github gitlab gitea googlechat gotify homeassistant ifttt ilert incident-io line matrix mattermost messagebird n8n newrelic opsgenie pagerduty plivo pushover rocketchat sendgrid signal signl4 slack splunk squadcast teams teams-workflows telegram twilio vonage webex zapier zulip]
2026/10/27 16:29:48 [config.ValidateEndpointsConfig] Validated 6 endpoints
2026/10/27 16:29:48 [config.ValidateEndpointsConfig] Validated 1 external endpoints
2026/10/27 16:29:48 [config.ValidateSuitesConfig] No suites configured
2026/10/27 16:29:48 [main.initializeStorage] Total endpoint keys to preserve: 7
2026/10/27 16:29:48 [main.initializeStorage] Loaded 1 persisted triggered alerts
2026/10/27 16:29:48 [controller.Handle] Listening on 0.0.0.0:8080
2026/10/27 16:29:48 [watchdog.executeEndpoint] Monitored group=public; endpoint=website; key=public_website; success=true; errors=0; duration=44ms
2026/10/27 16:29:49 [watchdog.executeEndpoint] Monitored group=internal; endpoint=ntfy-health; key=internal_ntfy-health; success=true; errors=0; duration=1ms
2026/10/27 16:29:49 [watchdog.executeEndpoint] Monitored group=public; endpoint=github-api; key=public_github-api; success=true; errors=0; duration=267ms
2026/10/27 16:29:49 [watchdog.executeEndpoint] Monitored group=internal; endpoint=ntfy-port; key=internal_ntfy-port; success=true; errors=0; duration=0s
2026/10/27 16:29:49 [watchdog.executeEndpoint] Monitored group=dns; endpoint=spf-record; key=dns_spf-record; success=true; errors=0; duration=17ms
2026/10/27 16:29:49 [watchdog.executeEndpoint] Monitored group=internal; endpoint=billing-api; key=internal_billing-api; success=false; errors=0; duration=0s
Two details in that log matter to you later. Loaded 1 persisted triggered alerts only appears after a restart (a first start will not print it), because SQLite keeps alert state in ./data and Gatus won’t re-send an alert that’s already open. And billing-api shows errors=0 even though it failed, which is ui.hide-errors at work: the connection error never gets stored for the page.
Compose shows the same picture on our lab host, with a filtered view of the log that you can reuse after every config change.

The image shows up as docker.io/twinproduction/gatus because the engine behind the Docker CLI in this lab was rootless Podman, which prints the full registry path. Docker Engine shows the short twinproduction/gatus name for the same image, and the Compose file and Gatus behave the same on both.
For a scripted check, the JSON API at /api/v1/endpoints/statuses returns each endpoint with its recent results. This one-liner prints the key, the verdict, each condition and any error from the newest result:
curl -s localhost:8080/api/v1/endpoints/statuses | python3 -c "
import json,sys
for e in json.load(sys.stdin):
r=e['results'][-1] if e.get('results') else {}
print(e['key'], r.get('success'), [ (c['condition'],c['success']) for c in r.get('conditionResults',[])], r.get('errors'))"
This is what it printed for us a minute after the very first start, before we added the two ui keys to the file.
dns_spf-record True [('[DNS_RCODE] == NOERROR', True), ('[BODY] == pat(*v=spf1*)', True)] None
internal_billing-api False [('[STATUS] (0) == 200', False)] ['Get "http://127.0.0.1:8091/health": dial tcp 127.0.0.1:8091: connect: connection refused']
internal_ntfy-health True [('[STATUS] == 200', True), ('[BODY].healthy == true', True)] None
internal_ntfy-port True [('[CONNECTED] == true', True)] None
public_github-api True [('[STATUS] == 200', True), ('[BODY].archived == false', True), ('[BODY].default_branch == master', True)] None
public_website True [('[STATUS] == 200', True), ('[RESPONSE_TIME] < 1500', True), ('[CERTIFICATE_EXPIRATION] > 720h', True)] None
Because hide-errors was not on yet, the connection error still shows for billing-api; with the file above it reads None. By default a failed condition shows the value Gatus got, [STATUS] (0) here, because no HTTP response came back at all.
Monitor gRPC, SSH and ICMP endpoints
Gatus picks the check type from the URL scheme, so grpc://, ssh:// and icmp:// need no plugin. These three ran from a second config on the same host against a local gRPC service and an SSH server listening on port 2222, and all three passed. Add them under endpoints: in your own file and change the targets to hosts you actually run.
The gRPC check calls the standard grpc.health.v1.Health/Check method, so it works against any service that registers the gRPC health server, and the reply status lands in [BODY].status:
- name: grpc-health
group: backend
url: "grpc://127.0.0.1:50051"
interval: 30s
client:
timeout: 5s
conditions:
- "[CONNECTED] == true"
- "[BODY].status == SERVING"
ui:
resolve-successful-conditions: true
Use grpcs:// for a TLS-protected service. The SSH check is more useful than a port probe, because it logs in with a private key, runs a command and lets you test the exit code and the output. This one fails when the root filesystem passes 85% full:
- name: ssh-disk-check
group: hosts
url: "ssh://127.0.0.1:2222"
interval: 1m
ssh:
username: "monitor"
private-key: |
-----BEGIN OPENSSH PRIVATE KEY-----
paste the private key of a dedicated, unprivileged account here
-----END OPENSSH PRIVATE KEY-----
body: |
{
"command": "df --output=pcent / | tail -1 | tr -dc '0-9'"
}
conditions:
- "[CONNECTED] == true"
- "[STATUS] == 0"
- "[BODY] < 85"
ui:
resolve-successful-conditions: true
Paste the key itself as a literal block, indented under private-key: |. Don’t try to inject it with ${VAR}, because Gatus substitutes variables before it parses the YAML and a multi-line key drops in without indentation. Key auth is the better choice over ssh.password, since the lab server refused passwords and the key can be scoped. Give the account a key that can do nothing but run the check, since anyone who can read your config.yaml can read that key too.
ICMP is the simplest of the three, and you only need the host and a latency limit.
- name: gateway-ping
group: hosts
url: "icmp://1.1.1.1"
interval: 30s
conditions:
- "[CONNECTED] == true"
- "[RESPONSE_TIME] < 100"
Current Gatus releases fall back to unprivileged ping when they don’t run as root, so the old setcap cap_net_raw step isn’t needed on most distros. It works as long as the host’s net.ipv4.ping_group_range covers the user Gatus runs as; on the lab host it was 0 2147483647, which allows everyone. Query the API of that instance on port 8081, this time printing the result duration too:
curl -s localhost:8081/api/v1/endpoints/statuses | python3 -c "
import json,sys
for e in json.load(sys.stdin):
r=e['results'][-1]
print(e['key'], r['success'], r.get('duration'), [c['condition'] for c in r.get('conditionResults',[])], r.get('errors'))"
With conditions resolved, the gRPC and SSH lines carry the real values the checks saw (the fourth endpoint in that config, an HTTPS check, is left out here):
backend_grpc-health True 3225737 ['[CONNECTED] (true) == true', '[BODY].status (SERVING) == SERVING'] None
hosts_gateway-ping True 5909941 ['[CONNECTED] == true', '[RESPONSE_TIME] < 100'] None
hosts_ssh-disk-check True 40147755 ['[CONNECTED] (true) == true', '[STATUS] (0) == 0', '[BODY] (15) < 85'] None
The disk sat at 15% and the SSH round trip, login included, took about 40 ms (the middle column is nanoseconds). If an SSH check flaps, look at that duration first: a slow key exchange on a busy host can push it past the client timeout before the command even runs.
Send Gatus alerts to ntfy, Slack and Zulip
An alert needs two parts: a provider block under alerting: and an alerts: entry on each endpoint that should use it. The default-alert in the provider sets thresholds once, but an endpoint without - type: ntfy never alerts, however the default is set.
With failure-threshold: 2, the broken billing-api endpoint fired after its second failed check. You can read the topic back from ntfy to see what Gatus sent.
curl -s "localhost:8090/gatus-alerts/json?poll=1"
The message names the group and endpoint, the failure count, your description and the failing condition, which is enough for whoever is on call to know where to look.
{"id":"Hx8xbvwulTRZ","time":1793118494,"expires":1793161694,"event":"message","topic":"gatus-alerts","title":"Gatus: internal/billing-api","message":"An alert has been triggered due to having failed 2 time(s) in a row with the following description: health check failed\n🔴 [STATUS] (0) == 200","priority":4,"tags":["rotating_light"]}
To close the loop, start anything that answers 200 on port 8091. After two good checks, Gatus sent the resolved message because send-on-resolved is on (it’s off by default, so set it if you want to know when things recover):
Gatus: internal/billing-api | An alert has been resolved after passing successfully 2 time(s) in a row with the following description: health check failed / 🟢 [STATUS] == 200 | ['white_check_mark']
The hosted providers use the same pattern. This block configures Slack, Discord, Telegram, email and Zulip at once, with placeholder credentials you replace with your own.
alerting:
slack:
webhook-url: "https://hooks.slack.com/services/T000/B000/XXXX"
discord:
webhook-url: "https://discord.com/api/webhooks/000000/XXXX"
telegram:
token: "123456:ABCDEF"
id: "-1001234567890"
email:
from: "[email protected]"
username: "[email protected]"
password: "app-password"
host: "smtp.example.com"
port: 587
to: "[email protected]"
zulip:
bot-email: "[email protected]"
bot-api-key: "zulip-api-key"
domain: "example.zulipchat.com"
channel-id: "ops"
topic: "[ENDPOINT_GROUP] / [ENDPOINT_NAME]"
Gatus accepted it and listed all five as configured: configuredProviders=[discord email slack telegram zulip]. That proves the keys are right, not that a message gets through, because those fake webhooks were never called. The README describes channel-id as the channel ID, so a numeric ID is the safe value even though Gatus passes it to Zulip’s channel field as is. The Zulip topic key is new and takes placeholders, so each endpoint gets its own thread instead of every alert landing under one default topic. Once you put real secrets in, move them into gatus.env and reference them as ${SLACK_WEBHOOK_URL} and so on.
Push cron job results to external endpoints
External endpoints flip the model: the job reports to Gatus instead of Gatus polling it, which suits backups, batch jobs and anything behind a firewall. The nightly-backup entry in the config has a token and a 26-hour heartbeat, so a missed night turns it red even if the job never runs at all.
The push URL uses the endpoint key, which is the group and name joined by an underscore, lowercased, with spaces and characters such as / _ , . # + & turned into dashes. Pull the token from the env file and report a success with its duration.
TOKEN=$(grep BACKUP_PUSH_TOKEN gatus.env | cut -d= -f2)
curl -s -o /dev/null -w "%{http_code}\n" -X POST -H "Authorization: Bearer ${TOKEN}" \
"http://localhost:8080/api/v1/endpoints/jobs_nightly-backup/external?success=true&duration=42s"
Gatus answers 200. The same request with a wrong token gets 401 and the body invalid token, so a typo in a cron script fails loudly instead of silently. The pushed duration shows on the dashboard as the response time, which is why nightly-backup reads about 42000 ms in the screenshot below. At the end of a backup script, report the result with success=false&error=... when the job fails.
Open the Gatus status page
Browse to http://10.0.1.50:8080, using your server’s address. The status page groups your endpoints, colours each check in the history bar and shows the average response time per card.

Click an endpoint for its history, uptime, response time chart and events. On billing-api the red block is the outage, the events list says how long it lasted, and the uptime badges already show 50% for the period.

Those badges are plain SVG URLs you can embed in a README or wiki, for example /api/v1/endpoints/public_website/uptimes/7d/badge.svg, with 1h, 24h, 7d or 30d as the window and /health/badge.svg for the current state. The badge URL still returned 200 with no credentials after basic auth was turned on in the next step, so treat endpoint names as public.
Planned work should not page anyone. A global maintenance window mutes alerts on a schedule while checks keep running and the page keeps showing the real state:
maintenance:
start: "23:00"
duration: 1h
timezone: "Africa/Nairobi"
every: [Sunday]
Without timezone the start time is UTC, and an empty every list means every day. A single endpoint can carry its own maintenance-windows list with the same keys when only one service has a patch slot.
Protect the dashboard with basic auth and TLS
Gatus wants the password as a bcrypt hash encoded in base64, not the password itself. You do not need htpasswd on the host, because the Apache image ships it. Generate the hash and append it to the env file.
GATUS_ADMIN_HASH=$(docker run --rm httpd:2.4-alpine htpasswd -bnBC 9 "" 'Str0ng-Dashboard-Pass' | tr -d ':\n' | base64 -w0 | tr '+/' '-_')
echo "GATUS_ADMIN_HASH=${GATUS_ADMIN_HASH}" >> gatus.env
Change the password before you run it. The final tr matters: Gatus decodes the value as URL-safe base64, so a + or / left in the string breaks it. Skip third-party “bcrypt generator” websites for this, since the password you type there leaves your machine.
For TLS, Gatus can serve HTTPS itself. Put the certificate and key in ~/gatus/certs; with a Let’s Encrypt certificate those are fullchain.pem and privkey.pem from /etc/letsencrypt/live/status.example.com/, which you can get with the certbot steps for Linux. Copy them as root with cp -L, because the files in live/ are symlinks into archive/ and a mounted symlink dangles inside the container. Gatus reads the pair once at startup, so a certbot deploy hook should copy the renewed files and restart the container. Gatus refuses to start when web.tls points at a missing file, so the pair has to exist before the next step. Without a domain yet, a self-signed pair like the one our lab used proves the wiring, although no browser will show a padlock for it:
mkdir -p certs
openssl req -x509 -newkey rsa:2048 -nodes -days 30 -subj "/CN=status.example.com" -addext "subjectAltName=DNS:status.example.com" -keyout certs/privkey.pem -out certs/fullchain.pem
Add the mount to the gatus service in compose.yaml.
volumes:
- ./config:/config:ro
- ./data:/data
- ./certs:/certs:ro
Then put the web and security blocks at the top of config/config.yaml, above ui:. Gatus watches its config file and reloads it about every 30 seconds, so a saved edit normally applies without a restart. Here that works against you, because the running container has neither the certificate mount nor the new hash, so run the up -d below right after you save.
web:
port: 9443
tls:
certificate-file: /certs/fullchain.pem
private-key-file: /certs/privkey.pem
security:
basic:
username: "admin"
password-bcrypt-base64: "${GATUS_ADMIN_HASH}"
The lab used 9443 because 8443 was already taken on that host; use 443 if nothing else holds it. Now recreate the container, and use up -d, not restart.
docker compose up -d
That difference cost a crash in the lab. docker compose restart reuses the container’s original environment, so the new GATUS_ADMIN_HASH never reached Gatus, the password field expanded to nothing, and Gatus exited with invalid security configuration. Recreating the container picks up the env file. Gatus now answers only on 9443 over TLS, so test the login there; -k skips certificate verification for the self-signed lab certificate, and you can drop it once a real certificate is in place:
curl -sk -o /dev/null -w "no auth: %{http_code}\n" https://localhost:9443/api/v1/endpoints/statuses
curl -sk -o /dev/null -w "wrong: %{http_code}\n" -u admin:nope https://localhost:9443/api/v1/endpoints/statuses
curl -sk -o /dev/null -w "right: %{http_code}\n" -u 'admin:Str0ng-Dashboard-Pass' https://localhost:9443/api/v1/endpoints/statuses
curl -sk -o /dev/null -w "health: %{http_code}\n" https://localhost:9443/health
The API refuses anonymous and wrong logins, while /health stays open for load balancer probes:
no auth: 401
wrong: 401
right: 200
health: 200
The statuses one-liner from earlier works the same way once you give it -sk, the login and the new URL. Run against the finished file, it shows what the two ui keys changed: github-api now prints the real values next to each passing condition, and billing-api no longer carries an error string:
curl -sk -u 'admin:Str0ng-Dashboard-Pass' https://localhost:9443/api/v1/endpoints/statuses | python3 -c "
import json,sys
for e in json.load(sys.stdin):
r=e['results'][-1] if e.get('results') else {}
print(e['key'], r.get('success'), [ (c['condition'],c['success']) for c in r.get('conditionResults',[])], r.get('errors'))"
All seven entries came back healthy, including the pushed nightly-backup result, which has no conditions of its own:
dns_spf-record True [('[DNS_RCODE] == NOERROR', True), ('[BODY] == pat(*v=spf1*)', True)] None
internal_billing-api True [('[STATUS] == 200', True)] None
internal_ntfy-health True [('[STATUS] == 200', True), ('[BODY].healthy == true', True)] None
internal_ntfy-port True [('[CONNECTED] == true', True)] None
jobs_nightly-backup True [] None
public_github-api True [('[STATUS] (200) == 200', True), ('[BODY].archived (false) == false', True), ('[BODY].default_branch (master) == master', True)] None
public_website True [('[STATUS] == 200', True), ('[RESPONSE_TIME] < 1500', True), ('[CERTIFICATE_EXPIRATION] > 720h', True)] None
With the web.tls block in place, you can confirm the handshake on the new port with openssl.
echo | openssl s_client -connect 127.0.0.1:9443 -servername status.example.com 2>/dev/null | grep -E "subject=|Protocol|Cipher is"
Gatus negotiated TLS 1.3 with the certificate from ./certs, and plain HTTP on 9443 got no answer at all:
subject=CN=status.example.com
New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256
Protocol: TLSv1.3
Anything that called the old address has to move as well. The cron push from the external endpoint section now goes to https://status.example.com:9443/api/v1/endpoints/jobs_nightly-backup/external; it still needs only the bearer token, not the dashboard login, and over TLS it returned 200 in our lab. Open 9443 in the host firewall only when the page is meant to be reachable from outside.
Run the Gatus binary without Docker
There’s no binary to download, but the image is built FROM scratch and holds one static executable at /gatus. Copy it out of the image when you want Gatus on a host without a container runtime.
docker create --name gatus-extract twinproduction/gatus:${GATUS_VERSION}
docker cp gatus-extract:/gatus ./gatus
docker rm gatus-extract
file gatus
The result is a statically linked x86-64 executable of about 51 MB, so it runs on any distro of the same architecture.
gatus: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=5Vmfook3KVXVydFytNjt/1EXVsxXFW9av6EKUafSq/Vw7suPQOlH3Ews_o8HHk/h3_HWoWjuzEVE_5xMZs8, BuildID[sha1]=a3395cb9889daf627a4154e41bed7d3f14b6d30b, with debug_info, not stripped
Point it at a config with GATUS_CONFIG_PATH. The gRPC, SSH and ICMP checks earlier ran exactly this way, as an unprivileged user with a config that set web.port: 8081:
GATUS_CONFIG_PATH=./config.yaml ./gatus
Do not look for a version subcommand. ./gatus version just starts Gatus, which panics with configuration file not found when no config is set; read the version from the image tag instead. For a permanent install, wrap the binary in a systemd unit that runs it as a dedicated user and sets GATUS_CONFIG_PATH, but the Compose route is less to maintain.
Fix Gatus startup errors
Gatus validates the whole config and panics on the first problem, so a bad file shows up as a container stuck in a restart loop. Each error below came up in our lab, and you can match yours by the exact text in docker logs gatus.
panic: error parsing config: invalid security configuration
Gatus gives no field name with this error, so you have to rule out the two things that trigger it. Check them in this order:
- An empty value, usually a
${VAR}missing from the container environment - An incomplete
basicoroidcblock undersecurity:
The first one is what bit us with restart above. The second arrived with v5.36.0 (PR #1531), which made every block under security: stand on its own. Older releases accepted the whole section if either basic or oidc was valid, so a half-written oidc block next to a working basic block slipped through validation. We ran the same file through both versions to see the difference.
web:
port: 8082
security:
basic:
username: "admin"
password-bcrypt-base64: "JDJhJDEwJHRNZG...."
oidc:
issuer-url: "https://auth.example.com"
client-id: "gatus"
endpoints:
- name: website
url: "https://example.com"
conditions:
- "[STATUS] == 200"
v5.35.0 passed config validation, loaded storage, and only died later when it tried to use the half-written OIDC block:
2026/10/27 19:44:03 [config.ValidateEndpointsConfig] Validated 1 endpoints
2026/10/27 19:44:03 [config.ValidateEndpointsConfig] Validated 0 external endpoints
2026/10/27 19:44:03 [config.ValidateSuitesConfig] No suites configured
2026/10/27 19:44:03 [store.Initialize] Creating storage provider of type=memory
2026/10/27 19:44:03 [main.initializeStorage] Total endpoint keys to preserve: 1
panic: Get "https://auth.example.com/.well-known/openid-configuration": dial tcp: lookup auth.example.com on 127.0.0.53:53: no such host
v5.37.0 refuses the file during config parsing, before it touches storage or the network:
2026/10/27 19:44:04 [main.configureLogging] Defaulting log level to INFO
2026/10/27 19:44:04 [config.LoadConfiguration] Reading configuration from configFile=./config.yaml
2026/10/27 19:44:04 [config.ValidateAlertingConfig] Alerting is not configured
panic: error parsing config: invalid security configuration
goroutine 1 [running]:
main.main()
/app/main.go:32 +0x21a
Either finish the OIDC block or delete it. A block that passes the new check has all five of these set:
issuer-urlredirect-url, ending in/authorization-code/callbackclient-idclient-secretscopes, usually["openid"]
Look closely at that basic block too, because it’s the one the first version of this guide printed. Both releases accept it, since they only check that the hash isn’t empty, but JDJhJDEwJHRNZG.... is a truncated example, so no password ever matches it. A username with no hash at all was rejected by both versions.
failed to listen: listen tcp 0.0.0.0:8443: bind: address already in use
With host networking, Gatus binds the host’s port directly, so anything else on that port wins. The log repeated Listening on 0.0.0.0:8443 followed by this error on every restart. Find the owner with sudo ss -ltnp | grep 8443, then change web.port or stop the other service.
gzip: stdin: not in gzip format
This comes from the old binary install that many Gatus tutorials still carry. GitHub answers the gatus_..._linux_amd64.tar.gz URL with a 404 page, curl -Lo saves the 9-byte Not Found text under the tarball’s name, and tar chokes on it:
gzip: stdin: not in gzip format
tar: Child returned status 1
tar: Error is not recoverable: exiting now
None of the last 100 Gatus releases, back to v1.9.0, carries a binary asset. Use the image, or copy the binary out of it as shown above.
Choose Gatus or Uptime Kuma
Pick Gatus when your checks belong in Git and get reviewed like code; pick Uptime Kuma when someone needs to add monitors by clicking. Neither replaces metrics, so keep Prometheus for time series and use either one for the outside-in view.
| Gatus | Uptime Kuma | |
|---|---|---|
| Configuration | One YAML file, environment variables for secrets | Web UI |
| Check types | HTTP, TCP, UDP, DNS, ICMP, SSH command, gRPC health, TLS, STARTTLS, WebSocket | 10 types listed in its README, including keyword, JSON query, push and Docker container |
| Alert integrations | 41 providers, counting the generic custom webhook | 90+ notification services |
| Push checks | External endpoints with a token and heartbeat | Push monitor |
| Default port | 8080 | 3001 |
| Footprint in our lab | About 35 MB RSS with 7 endpoints | Not measured |
The Uptime Kuma column comes from its README rather than our lab. For a small team the deciding question is who edits the monitors. If it’s engineers who already open pull requests, Gatus’s single file is easier to review and roll back than a database of clicks. If it is support staff, Uptime Kuma wins. For a stack that already runs Prometheus and Grafana, turn on metrics: true in Gatus and it exposes /metrics on the web port for scraping.