Can I self-host Pingdom?
YES · ONE COMMAND— setup effort 1 of 4YES — it's called Gatus. It takes one prompt, a 512 MB VPS, and about 9 minutes. That is $18 a month you stop paying Pingdom — $216 a year on the Synthetic Monitoring · 10 uptime checks plan, a metered rate, not a whole bill.
Why people pay for Pingdom
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Pingdom watches your site from over a hundred places that are not your site, and that vantage point is the part a single self-hosted instance cannot honestly reproduce. The bill also buys real user monitoring from the browsers of actual visitors, SMS alerts that still arrive on the morning your own infrastructure is the thing that broke, and a public status page somebody else keeps online. SolarWinds sells the synthetic half and the real-user half as separate quantities added to one subscription.
| Plan | List price | What it buys |
|---|---|---|
| Synthetic Monitoring · 10 uptime checksthe plan this page prices against | $18/mo metered | The smallest step on the quantity slider: 10 uptime checks, 1 advanced transaction check, 50 SMS alerts, unlimited users. $16.50/mo billed annually. |
| Synthetic Monitoring · 50 uptime checks | $78/mo metered | 50 uptime checks, 10 advanced checks, 200 SMS alerts. $71.50/mo billed annually. |
| Synthetic Monitoring · 100 uptime checks | $149/mo metered | 100 uptime checks, 20 advanced checks, 350 SMS alerts. $136.42/mo billed annually. The slider continues past this to 30,000 checks at $28,548/mo. |
| Real User Monitoring · 100,000 pageviews | $18/mo metered | A separate quantity from the synthetic checks, added to the same subscription. Unlimited sites and users, 13-month retention. $16.50/mo billed annually. |
| Real User Monitoring · 1 million pageviews | $118/mo metered | $107.83/mo billed annually. The slider continues to 1 billion pageviews at $23,400/mo. |
| Enterprise | quote only | Quote only. The page offers a request-quote form for 500K to over 5M pageviews a month. |
Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: medium
Replaced by Gatus
One project, named before the prompt, so you know what you are about to install.
A public status page whose entire configuration is one YAML file you own: the checks, the pass conditions and the alerting, declared once and served as a page.
The closest match to what Pingdom's synthetic tier is actually used for, and the honest one about the difference. Checks, pass conditions, alert rules and the public status page are declared in a single YAML file you own, so a monitor is a diff rather than a form, and the conditions go past `is it 200` to response time, JSON body fields, certificate expiry and domain expiry. What it cannot do is watch from a hundred cities, and it cannot report the outage that takes down the machine it runs on, which is why the page tells you to keep one free external check pointed at it.
What else we looked at
Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.
- Gatusour pickONE COMMAND— setup effort 1 of 4
A public status page whose entire configuration is one YAML file you own: the checks, the pass conditions and the alerting, declared once and served as a page.
The closest match to what Pingdom's synthetic tier is actually used for, and the honest one about the difference. Checks, pass conditions, alert rules and the public status page are declared in a single YAML file you own, so a monitor is a diff rather than a form, and the conditions go past `is it 200` to response time, JSON body fields, certificate expiry and domain expiry. What it cannot do is watch from a hundred cities, and it cannot report the outage that takes down the machine it runs on, which is why the page tells you to keep one free external check pointed at it.
- Uptime KumaONE COMMAND— setup effort 1 of 4
Uptime monitoring and status pages from one container, with no monitor quota and no per-check billing.
The same job with the opposite ergonomics: monitors are clicked into a signed-in dashboard rather than declared in a file, and the notification integrations are broader out of the box. Pick it if you want to add a check on your phone at midnight. Pick Gatus if the checks belong in version control and the public status page is the point rather than a side feature.
The swap
You'd run
Gatus
ONE COMMAND · ~9 min to running · 512 MB RAM
Pingdom Synthetic Monitoring · 10 uptime checks · a metered rate, not a whole bill · vendor list price · checked 2026-08-06 · source · confidence: medium
Before you start
- RAM floor
- 512 MBfloor from upstream docs — not measured by us yet
- Disk
- 5 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~9 minunder 10 minutes, through the first backup
The prompt
Two paths to the same Gatus: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
334 lines · 14,988 bytes
What this prompt will do
- Preflight
- Layout and configuration
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install Gatus 5.36.0 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.
## 1. Preflight
If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server.
Say two things to the user first. One: a monitor cannot tell them the machine it runs on is down.
If they have a second server this belongs on the other one, and either way they should keep one
free external check pointed at this hostname from a service they do not run, because that check
survives the outage this install cannot report. Two: this produces a public status page. There is
no login and no account, and every endpoint name and URL written in step 2 is published to anyone
who loads it.
Gatus needs 512 MB of RAM available and 5 GB free on /srv. The image publishes amd64, arm64 and
arm/v7. Measure all four:
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
If available RAM is under 512 MB or free disk is under 5 GB, print both numbers and stop. Do not
install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot certify a
hostname that does not resolve, and step 2 writes that hostname into a check that queries it.
## 2. Layout and configuration
The configuration file is the product. It carries the monitors, their pass conditions and the
alerting, it is the only thing the user edits after today, and Gatus refuses to start without it,
so it is written before the container ever runs. Replace `<DOMAIN>` with the real hostname below.
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/gatus /srv/gatus/backups /srv/gatus/config
sudo install -d -m 750 /srv/gatus/data
cat > /srv/gatus/config/config.yaml <<'EOF'
# Gatus · the configuration is the product. Authored by caniselfhostit from
# https://github.com/TwiN/gatus/blob/v5.36.0/README.md#configuration
#
# Every name and every URL below is printed on a page anyone can open.
storage:
type: sqlite
path: /data/data.db
endpoints:
- name: gatus
group: internal
url: "http://127.0.0.1:8080/health"
interval: 60s
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- name: status-page
group: public
url: "https://<DOMAIN>/health"
interval: 60s
conditions:
- "[STATUS] == 200"
- "[RESPONSE_TIME] < 1000"
- "[CERTIFICATE_EXPIRATION] > 240h"
- name: dns
group: public
url: "1.1.1.1"
interval: 5m
dns:
query-name: "<DOMAIN>"
query-type: "A"
conditions:
- "[DNS_RCODE] == NOERROR"
# Alerting is off. Every provider wants a webhook URL or a key from a service
# you sign up for. To turn Slack on: uncomment, paste your own webhook URL, and
# add an `alerts:` list with `- type: slack` under an endpoint.
#alerting:
# slack:
# webhook-url: "PASTE_YOUR_OWN_SLACK_WEBHOOK_URL_HERE"
EOF
chmod 600 /srv/gatus/config/config.yaml
ls -la /srv/gatus /srv/gatus/config
```
Assert: `ls -la` shows `config.yaml` at mode `-rw-------`, `data` owned by root, and `backups`
owned by the login user. The file holds no secret today and is mode 600 because the alerting
stanza is where one would land later; the container reads it as root whatever the mode says.
`data` is root-owned because the image declares no user, so the process inside runs as root and
creates `data.db` itself.
## 3. Secrets
No secret is generated for this install and there is no `.env` file. That is not an oversight,
and there is no default credential for step 7 to close: Gatus ships no account, no registration
form and no administration screen. Exactly one route writes anything, the push endpoint for
externally reported checks, and it answers `401` to any call arriving without a bearer token the
user declared in the configuration first. This install declares none, so that route has nothing
to accept. Step 7 asserts it.
What replaces the credential question here is publication. The dashboard and its JSON API answer
everybody, because that is what a status page is for, and step 2's file decides what everybody
sees: the name of every endpoint, its URL, and whether it is passing right now. An internal
hostname in that file is an internal hostname on a public page.
Tell the user this: if they would rather the page were behind a password, upstream's
`security.basic` and `security.oidc` do that, this install uses neither, and turning one on is a
decision about who the page is for.
## 4. compose.yml
```bash
cat > /srv/gatus/compose.yml <<'EOF'
# Gatus · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker deployment .. https://github.com/TwiN/gatus/blob/v5.36.0/README.md#docker
# configuration ...... https://github.com/TwiN/gatus/blob/v5.36.0/README.md#configuration
# storage ............ https://github.com/TwiN/gatus/blob/v5.36.0/README.md#storage
# image build ........ https://github.com/TwiN/gatus/blob/v5.36.0/Dockerfile
#
# One service, no database container: storage is SQLite in the /data mount,
# because upstream's default is memory and upstream says memory does not survive
# a restart. The config directory is mounted rather than the single file, since
# Gatus polls the loaded path for changes and upstream reports that binding the
# file hides them. No healthcheck and no `user:` line: the image is built FROM
# scratch and carries the binary and the CA bundle, so there is no shell to run
# a check with and no user database to name a user from. No .env: this install
# generates no secret. Digest read from ghcr.io on 2026-08-06; the manifest list
# covers amd64, arm64 and arm/v7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
gatus:
image: ghcr.io/twin/gatus:v5.36.0@sha256:c5f210d095fa78e6efaa20ffeb14803f2ba4f10615e16a6d12087697149617f0
container_name: gatus
restart: unless-stopped
environment:
# DEBUG here while a check fails for a reason the dashboard will not say.
GATUS_LOG_LEVEL: INFO
volumes:
# config.yaml is the product. Read only: Gatus never writes here.
- /srv/gatus/config:/config:ro
# data.db, the SQLite file holding every result and every uptime figure.
- /srv/gatus/data:/data
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8132.
- "127.0.0.1:8132:8080"
EOF
cd /srv/gatus && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. One service, one published port, no database container:
results, uptime figures and event history are rows in `data/data.db`.
## 5. Caddy and TLS
Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-gatus
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Gatus · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/TwiN/gatus/blob/v5.36.0/README.md#deployment and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. Everything served here
# is public on purpose: a status page nobody can open is a log file.
<DOMAIN> {
# Gatus compresses its own responses when the browser asks, so this mostly
# covers what the container hands over uncompressed.
encode zstd gzip
# About how the page is framed and referred to, not about protecting a
# session. There is no session here: no accounts, no login, no cookie.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
# 8132 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8132
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-gatus, reload, and report what it objected to. Caddy requests the
certificate on the first request and renews it on its own, so there is nothing to schedule.
## 6. Firewall
Two ports open, both Caddy's. These are idempotent, so on a box Prompt Zero configured they
change nothing:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, and 443/udp
is HTTP/3. 8132 stays closed because compose binds it to 127.0.0.1. The checks need nothing
opened: they are outbound, and default-deny governs what arrives. Assert:
`ufw status verbose` prints `Status: active`, shows 80, 443/tcp and 443/udp, and no rule
mentioning 8132 or 8080.
## 7. Start and verify
Gatus runs every endpoint once at start-up rather than waiting out the first interval, so results
exist seconds after the container comes up.
```bash
cd /srv/gatus
docker compose pull
docker compose up -d
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS https://<DOMAIN>/health; echo
curl -sSL https://<DOMAIN>/ | grep -c 'Health Dashboard'
curl -sS https://<DOMAIN>/api/v1/endpoints/statuses | grep -o '"key":"[^"]*"'
curl -sS -o /dev/null -w '%{http_code}\n' -X POST 'https://<DOMAIN>/api/v1/endpoints/public_status-page/external?success=true'
```
Assert all five, and print what you received for each. The loop ends printing `200`. The health
endpoint answers `{"status":"UP"}`. The grep prints a number greater than `0`, because
`Health Dashboard` is the heading the page renders. The statuses call prints three lines, one
`"key"` per endpoint: `internal_gatus`, `public_status-page` and `public_dns`. The last command prints `401`: that push route is
the only thing here that writes, and it refuses a call carrying no bearer token, which is the
security assert in this block. If any of the five misses, stop, run
`docker compose logs --tail 40 gatus`, and name the likely earlier step: a container that exits
on its own is step 2, because a configuration Gatus cannot parse makes it refuse to start rather
than ignore the file, and a 502 from Caddy with a running container is step 5. A running
container is not success.
The `status-page` row may be red on the first pass: it checks this host through its own
certificate, which Caddy may still have been issuing when Gatus first asked. Green within a
minute or two; red past five means this box cannot reach its own public URL, which a few
provider networks refuse.
STOP: tell the user to open https://<DOMAIN> in a private window, read the page as a stranger
would, and confirm two things back to you: that they see three rows under the headings `internal`
and `public`, and that they are content for those names and URLs to be public, because they now
are. Do not continue until they confirm.
## 8. First backup and restore
One archive: the configuration, the results database, the compose file and the live Caddy site
block. Take it now, before there is a month of history to lose.
```bash
cd /srv/gatus
docker compose stop
sudo tar -czf /srv/gatus/backups/gatus-$(date +%F).tar.gz -C /srv/gatus config data compose.yml -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/gatus/backups/
```
Assert: the archive exists and is non-empty. Print its size. Downtime is about five seconds, and
the container is stopped on purpose, because a SQLite file copied mid-write is not a backup.
A backup on the same disk as the data is not a backup. Run this one from the user's machine, not
the server:
```bash
mkdir -p ~/backups/gatus
scp vps:/srv/gatus/backups/*.tar.gz ~/backups/gatus/
```
To restore: `docker compose down`, `sudo rm -rf /srv/gatus/data /srv/gatus/config`, recreate the
directories as in step 2, untar the archive back into /srv/gatus, put the Caddy block back if
that is what was lost, then `docker compose up -d`. Tell the user which half matters:
`config/config.yaml` is every monitor they ever wrote, and `data/data.db` is only the history
behind those monitors. Losing the second costs the uptime figures. Losing the first costs the
product.
## 9. Updating later
New versions are listed at https://github.com/TwiN/gatus/releases. The release tag and the image
tag are the same string, so release `v5.37.0` is image tag `v5.37.0`. Take a backup first, then
edit the image line in /srv/gatus/compose.yml to the new tag and its digest:
```bash
cd /srv/gatus
docker compose pull
docker compose up -d
docker compose logs --tail 30 gatus
```
Gatus migrates the SQLite schema on the way up. Watch that log until it settles, then re-run
step 7's checks before calling the update done.
## 10. What will probably go wrong
You will open config.yaml to add your first real monitor, save it, and half a minute later the
container will be gone. I did that with a mis-indented `conditions:` list. Gatus polls its own
configuration while it runs, and upstream's default when the new file does not parse is to exit
rather than keep serving the old one, so `restart: unless-stopped` starts it again, it reads the
same broken file, and it exits again. The dashboard goes down with it, a poor look for a status
page. Run `docker compose logs --tail 20 gatus`, read the parse error at the top, fix that line
in /srv/gatus/config/config.yaml, and the next restart picks it up. Do not set
`skip-invalid-config-update` to true to stop the symptom: upstream recommends against it, because
the broken file then survives quietly until the next real restart, which stops the container
anyway.
## 11. Out of scope
- Do not configure an alerting provider. Each one needs a webhook URL or a key from a service
the user signs up for, and the commented block in config.yaml is where theirs goes.
- Do not turn on `security.basic` or `security.oidc`. Whether this page is public is the user's
editorial decision, and the OIDC path needs an identity provider registered somewhere else.
- Do not switch `storage.type` to postgres. SQLite is the choice here, and it is the reason this
is one container and one file to copy.
- Do not set `metrics: true` and do not add Prometheus or Grafana beside it. This installs one
service.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.
You are installing Gatus 5.36.0 on a VPS where Prompt Zero is done: `ssh vps` works, Docker and
Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a step
says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at the
box.
Read these two before step 1, because together they decide whether you want this at all. A
monitor cannot tell you that the machine it runs on is down, so this belongs on a box other than
the ones it watches, and you should keep one free external check pointed at this hostname from a
service you do not run. And what you are building is a public status page: no login, no account,
and the name and URL of every endpoint in the configuration file are published to whoever loads
the page.
## 1. Preflight
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
You should see: at least `512` MB available, at least `5` G free, `amd64` or `arm64`, and your
server's IP on the last line.
If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, failed attempts count against a rate limit you cannot see, and step 2 writes that same
hostname into a check that queries it, so an unresolvable name gives you a red row as well as no
certificate. An architecture line reading `armhf` is also fine: the image publishes amd64, arm64
and arm/v7.
## 2. Layout and configuration
The configuration file is the product. It carries the monitors, their pass conditions and the
alerting, it is the only thing you edit after today, and Gatus will not start without it, so it
gets written before the container has ever run. Replace `<DOMAIN>` on the two lines that carry it
before you paste, and paste the whole block at once.
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/gatus /srv/gatus/backups /srv/gatus/config
sudo install -d -m 750 /srv/gatus/data
cat > /srv/gatus/config/config.yaml <<'EOF'
# Gatus · the configuration is the product. Authored by caniselfhostit from
# https://github.com/TwiN/gatus/blob/v5.36.0/README.md#configuration
#
# Every name and every URL below is printed on a page anyone can open.
storage:
type: sqlite
path: /data/data.db
endpoints:
- name: gatus
group: internal
url: "http://127.0.0.1:8080/health"
interval: 60s
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- name: status-page
group: public
url: "https://<DOMAIN>/health"
interval: 60s
conditions:
- "[STATUS] == 200"
- "[RESPONSE_TIME] < 1000"
- "[CERTIFICATE_EXPIRATION] > 240h"
- name: dns
group: public
url: "1.1.1.1"
interval: 5m
dns:
query-name: "<DOMAIN>"
query-type: "A"
conditions:
- "[DNS_RCODE] == NOERROR"
# Alerting is off. Every provider wants a webhook URL or a key from a service
# you sign up for. To turn Slack on: uncomment, paste your own webhook URL, and
# add an `alerts:` list with `- type: slack` under an endpoint.
#alerting:
# slack:
# webhook-url: "PASTE_YOUR_OWN_SLACK_WEBHOOK_URL_HERE"
EOF
chmod 600 /srv/gatus/config/config.yaml
ls -la /srv/gatus /srv/gatus/config
```
You should see: `config`, `data` and `backups` under /srv/gatus, `data` owned by `root`, and
`config.yaml` at mode `-rw-------`.
If you do not: leave `data` owned by root on purpose. The image declares no user, so the process
inside the container runs as root and creates `data.db` there itself. If `ls` shows no
`config.yaml`, the heredoc did not close: the last three lines of the paste are `EOF`, the
`chmod`, and the `ls`, and a shell still showing a `>` prompt is waiting for that `EOF`. Press
Ctrl-C and paste the block again in one go.
## 3. Secrets
There are none, and that is the whole block. Gatus ships no account, no registration form and no
administration screen, so this install generates nothing, writes no `.env`, and leaves no default
credential for step 7 to close. Exactly one route writes anything, the push endpoint for
externally reported checks, and it refuses any call arriving without a bearer token that the
configuration file would have had to declare first. Yours declares none. Step 7 proves it.
```bash
ls -l /srv/gatus/config/config.yaml
sudo grep -c . /srv/gatus/config/config.yaml
```
You should see: mode `-rw-------`, your own username twice, and a line count around `40`.
If you do not: a mode of `-rw-r--r--` means the `chmod` line in step 2 did not run. Run
`chmod 600 /srv/gatus/config/config.yaml` and carry on.
Do not paste that file, or any command output containing it, into this chat window. Nothing in it
is a secret today. The moment you uncomment the alerting block and put a real Slack webhook URL
there, it is one, and a webhook URL is a working credential to anyone who reads it.
The other half of this block is not about credentials at all. The dashboard and its JSON API
answer everybody, because that is what a status page is for, and the file you wrote in step 2
decides what everybody sees: the name of every endpoint, its URL, and whether it is passing right
now. An internal hostname in that file is an internal hostname on a public page. If you would
rather the page were behind a password, upstream's `security.basic` and `security.oidc` settings
do that; this install uses neither, and turning one on is a decision about who the page is for.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/gatus/compose.yml <<'EOF'
# Gatus · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker deployment .. https://github.com/TwiN/gatus/blob/v5.36.0/README.md#docker
# configuration ...... https://github.com/TwiN/gatus/blob/v5.36.0/README.md#configuration
# storage ............ https://github.com/TwiN/gatus/blob/v5.36.0/README.md#storage
# image build ........ https://github.com/TwiN/gatus/blob/v5.36.0/Dockerfile
#
# One service, no database container: storage is SQLite in the /data mount,
# because upstream's default is memory and upstream says memory does not survive
# a restart. The config directory is mounted rather than the single file, since
# Gatus polls the loaded path for changes and upstream reports that binding the
# file hides them. No healthcheck and no `user:` line: the image is built FROM
# scratch and carries the binary and the CA bundle, so there is no shell to run
# a check with and no user database to name a user from. No .env: this install
# generates no secret. Digest read from ghcr.io on 2026-08-06; the manifest list
# covers amd64, arm64 and arm/v7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
gatus:
image: ghcr.io/twin/gatus:v5.36.0@sha256:c5f210d095fa78e6efaa20ffeb14803f2ba4f10615e16a6d12087697149617f0
container_name: gatus
restart: unless-stopped
environment:
# DEBUG here while a check fails for a reason the dashboard will not say.
GATUS_LOG_LEVEL: INFO
volumes:
# config.yaml is the product. Read only: Gatus never writes here.
- /srv/gatus/config:/config:ro
# data.db, the SQLite file holding every result and every uptime figure.
- /srv/gatus/data:/data
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8132.
- "127.0.0.1:8132:8080"
EOF
cd /srv/gatus && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `services must be a mapping` means the indentation was lost between the page and
your terminal. Run `rm /srv/gatus/compose.yml` and paste again in one go. There is no database
service in this file and that is correct: the results, the uptime figures and the event history
are rows in `data/data.db`, which is why step 2 made that directory.
## 5. Caddy and TLS
This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-gatus
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Gatus · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/TwiN/gatus/blob/v5.36.0/README.md#deployment and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. Everything served here
# is public on purpose: a status page nobody can open is a log file.
<DOMAIN> {
# Gatus compresses its own responses when the browser asks, so this mostly
# covers what the container hands over uncompressed.
encode zstd gzip
# About how the page is framed and referred to, not about protecting a
# session. There is no session here: no accounts, no login, no cookie.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
# 8132 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8132
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
You should see: `Valid configuration` from validate, and no output at all from reload.
If you do not: run `sudo cp /etc/caddy/Caddyfile.before-gatus /etc/caddy/Caddyfile`, reload, and
paste again. The commonest cause is a `<DOMAIN>` you replaced in the site line but left in the
comment above it, which is harmless, or one you left in the site line, which is not. Caddy
requests the certificate on the first request to the hostname and renews it on its own.
## 6. Firewall
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8132` or `8080`.
If you do not: delete anything for `8132` with `sudo ufw delete allow 8132`. It is bound to
127.0.0.1 by the compose file, so a rule for it would cover traffic that cannot arrive. Your
checks need nothing opened either: they are outbound requests from the container, and ufw governs
what arrives. `Status: inactive` is a different problem, because Prompt Zero left this firewall
on, so something has turned it off since; `sudo ufw enable` puts it back.
## 7. Start and verify
Gatus runs every endpoint once at start-up rather than waiting out the first interval, so results
exist seconds after the container comes up.
```bash
cd /srv/gatus
docker compose pull
docker compose up -d
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS https://<DOMAIN>/health; echo
curl -sSL https://<DOMAIN>/ | grep -c 'Health Dashboard'
curl -sS https://<DOMAIN>/api/v1/endpoints/statuses | grep -o '"key":"[^"]*"'
curl -sS -o /dev/null -w '%{http_code}\n' -X POST 'https://<DOMAIN>/api/v1/endpoints/public_status-page/external?success=true'
```
You should see, in order: the loop reaching `200`, then `{"status":"UP"}`, then a number greater
than `0` because `Health Dashboard` is the heading the page renders, then three lines reading
`"key":"internal_gatus"`, `"key":"public_status-page"` and `"key":"public_dns"` in some order,
then `401`.
If you do not: that `401` is the one worth understanding, so read it as good news. It means the
only route in this application that writes anything refused a call carrying no bearer token, and
because your configuration declares no external endpoint there is no token that would work. A
`404` from the first three instead means Caddy is reaching something other than Gatus: check
`docker compose ps`. If the container is not running at all, run
`docker compose logs --tail 40 gatus` and look for a parse error, because a configuration Gatus
cannot read makes it refuse to start rather than ignore the file, and that points at step 2.
Now open https://<DOMAIN> in a private window and read it the way a stranger would. You should
see three rows under the headings `internal` and `public`. The `status-page` row may be red on
this first pass, which is correct rather than broken: it checks this host through its own
certificate, and Caddy may still have been issuing that certificate when Gatus first asked.
Green within a minute or two. Red past five minutes means the server cannot reach its own
public URL, which a few providers' networks refuse; run `curl -sS https://<DOMAIN>/health` on
the server itself to see which side is failing. A running container is not success; that page
is.
Before you go further, look at what is on it. The names and the URLs you wrote in step 2 are now
public. If either of them is something you would rather strangers did not know about, edit
/srv/gatus/config/config.yaml now, because Gatus rereads it within about thirty seconds.
## 8. First backup and restore
One archive: the configuration, the results database, the compose file and the live Caddy site
block.
```bash
cd /srv/gatus
docker compose stop
sudo tar -czf /srv/gatus/backups/gatus-$(date +%F).tar.gz -C /srv/gatus config data compose.yml -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/gatus/backups/
```
You should see: one file, a few kilobytes on a fresh install. Downtime is about five seconds, and
the container is stopped on purpose, because a SQLite file copied mid-write is not a backup.
If you do not: an archive of about 100 bytes means `tar` found none of the paths, which happens
if you ran it from somewhere other than /srv/gatus with a typo in a `-C` argument. Run
`tar -tzf` on it and read what it actually contains.
A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:
```bash
mkdir -p ~/backups/gatus
scp vps:/srv/gatus/backups/*.tar.gz ~/backups/gatus/
```
You should see: one file copied, and it listed by `ls -lh ~/backups/gatus/`.
If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the alias Prompt Zero created lives.
Now prove the restore, today, while the only thing at risk is an hour of history:
```bash
cd /srv/gatus
docker compose down
sudo rm -rf /srv/gatus/data /srv/gatus/config
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/gatus/config
sudo install -d -m 750 /srv/gatus/data
sudo tar -C /srv/gatus -xzf /srv/gatus/backups/gatus-$(date +%F).tar.gz config data compose.yml
docker compose up -d
sleep 20
curl -sS https://<DOMAIN>/api/v1/endpoints/statuses | grep -o '"key":"[^"]*"'
```
You should see: the same three keys as in step 7, from a config directory and a database that
were both deleted a minute ago.
If you do not: `tar: config: Not found in archive` means you passed a date that does not match
the filename, so run `ls /srv/gatus/backups/` and use the real one. Know which half matters:
`config/config.yaml` is every monitor you ever wrote, and `data/data.db` is only the history
behind them. Losing the second costs your uptime figures. Losing the first costs the product.
## 9. Updating later
New versions are listed at https://github.com/TwiN/gatus/releases. The release tag and the image
tag are the same string, so release `v5.37.0` is image tag `v5.37.0`. Take a backup first, then
edit the `image:` line in /srv/gatus/compose.yml to the new tag and its digest.
```bash
cd /srv/gatus
docker compose pull
docker compose up -d
docker compose logs --tail 30 gatus
```
You should see: the server starting, no repeating restart, and no line about an invalid
configuration.
If you do not: put the old tag and digest back and run the same three commands. Gatus migrates
the SQLite schema on the way up, so watch that log until it settles, then re-run the `/health`
and statuses checks from step 7 before you call the update done.
## 10. What will probably go wrong
You will open config.yaml to add your first real monitor, save it, and half a minute later the
container will be gone. I did that with a mis-indented `conditions:` list. Gatus polls its own
configuration while it runs, and upstream's default when the new file does not parse is to exit
rather than keep serving the old one, so `restart: unless-stopped` starts it again, it reads the
same broken file, and it exits again. The dashboard goes down with it, a poor look for a status
page. Run `docker compose logs --tail 20 gatus`, read the parse error at the top, fix that line
in /srv/gatus/config/config.yaml, and the next restart picks it up. Do not set
`skip-invalid-config-update` to true to stop the symptom: upstream recommends against it, because
the broken file then survives quietly until the next real restart, which stops the container
anyway.
## 11. Out of scope
- Do not configure an alerting provider yet. Each one needs a webhook URL or a key from a service
you sign up for, and the commented block in config.yaml is where yours goes.
- Do not turn on `security.basic` or `security.oidc`. Whether this page is public is your
editorial decision, and the OIDC path needs an identity provider registered somewhere else.
- Do not switch `storage.type` to postgres. SQLite is the choice here, and it is the reason this
is one container and one file to copy.
- Do not set `metrics: true` and do not add Prometheus or Grafana beside it. This install is one
service.309 lines · 14,934 bytes
What this prompt will do
- Preflight
- Docker
- Layout and configuration
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.
Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.
Install Gatus 5.36.0 under ~/selfhost/gatus, answering at http://localhost:8132.
## 1. Preflight
Say this to the user before step 2 runs, because it decides whether they want this install at
all. A status page exists so that other people can read it, and this one answers at
http://localhost:8132, which means this computer and nothing else. Worse for a monitor: checks
run only while this machine is awake, and a closed laptop does not record an outage, it records
nothing. What they get is a private watchtower over their own sites that works while they are at
this desk.
Detect the OS and measure the machine:
```bash
uname -s
case "$(uname -s)" in
Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```
`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. Gatus needs 512 MB of RAM available and 5 GB
free on the home disk, and the image publishes amd64, arm64 and arm/v7. Every branch prints free
memory, so one floor covers all three; on macOS and Windows it is the host's, and Docker Desktop
takes its allocation out of it. If available RAM is under 512 MB or free disk is under 5 GB,
print both numbers and stop. Do not install and hope.
## 2. Docker
Check before installing anything:
```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```
If that printed `docker OK` and a compose version, skip to step 3.
Otherwise, install Docker for the OS step 1 detected:
- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
Homebrew, STOP: tell the user to download Docker Desktop from
https://www.docker.com/products/docker-desktop/ and install it, and wait until they
confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
terms, and wait for the whale icon to say it is running. Do not continue until they
confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
install fails, STOP: tell the user to download Docker Desktop from the URL above and
install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
repository, with its signing key saved to a file first, never piped into a shell. The
fence is guarded, a no-op on anything but a Linux with apt:
```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker "$USER"
fi
```
Adding the user to the docker group is root-equivalent on this machine; say that to the
user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
plugin with their distribution's package manager, and to run this prompt again once
`docker info` works.
Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.
## 3. Layout and configuration
The configuration file is the product. It carries the monitors, their pass conditions and the
alerting, it is the only thing the user edits after today, and Gatus will not start without it,
so it is written before the container ever runs.
```bash
mkdir -p ~/selfhost/gatus/config ~/selfhost/gatus/data ~/selfhost/gatus/backups
cat > ~/selfhost/gatus/config/config.yaml <<'EOF'
# Gatus · the configuration is the product. Authored by caniselfhostit from
# https://github.com/TwiN/gatus/blob/v5.36.0/README.md#configuration
#
# example.org is a placeholder. Replace it with a site you actually care about.
storage:
type: sqlite
path: /data/data.db
endpoints:
- name: gatus
group: internal
url: "http://127.0.0.1:8080/health"
interval: 60s
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- name: example-site
group: watched
url: "https://example.org/"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[RESPONSE_TIME] < 2000"
- "[CERTIFICATE_EXPIRATION] > 240h"
# Alerting is off. Every provider wants a webhook URL or a key from a service
# you sign up for. To turn Slack on: uncomment, paste your own webhook URL, and
# add an `alerts:` list with `- type: slack` under an endpoint.
#alerting:
# slack:
# webhook-url: "PASTE_YOUR_OWN_SLACK_WEBHOOK_URL_HERE"
EOF
chmod 600 ~/selfhost/gatus/config/config.yaml
ls -la ~/selfhost/gatus ~/selfhost/gatus/config
```
Assert: `ls -la` shows `config`, `data` and `backups`, and `config.yaml` at mode `-rw-------`.
No ownership fix is needed on any of the three systems: the image declares no user, so the
process runs as root and creates `data.db` in the mounted folder itself. The mode is 600 because
the alerting stanza is where a secret first lands. On Windows mode bits are advisory, and the
real boundary is the user's own Windows account.
## 4. Secrets
No secret is generated for this install and there is no `.env` file. Gatus ships no account, no
registration form and no administration screen, so there is nothing to name, rotate or close.
Exactly one route writes anything, the push endpoint for externally reported checks, and it
answers `401` to any call with no bearer token declared in the configuration first. Step 3
declared none, so that route has nothing to accept, and step 7 asserts it.
## 5. compose.yml
```bash
cat > ~/selfhost/gatus/compose.yml <<'EOF'
# Gatus · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# configuration ...... https://github.com/TwiN/gatus/blob/v5.36.0/README.md#configuration
# image build ........ https://github.com/TwiN/gatus/blob/v5.36.0/Dockerfile
#
# One service on the computer you are sitting at. Both paths are relative to
# ~/selfhost/gatus/, so one file works on macOS, Linux and Windows, and both
# stay bind mounts rather than named volumes, so you can open config.yaml in
# Finder or Explorer. Storage is SQLite in ./data, because upstream's default is
# memory and upstream
# says memory does not survive a restart. The config directory is mounted rather
# than the single file, since Gatus polls the loaded path for changes and
# upstream reports that binding the file hides them. No healthcheck and no
# `user:` line, because the image is built FROM scratch: no shell to run a check
# with, no user database to name a user from. No .env: no secret. Digest read
# from ghcr.io on 2026-08-06; the list covers amd64, arm64 and arm/v7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
gatus:
image: ghcr.io/twin/gatus:v5.36.0@sha256:c5f210d095fa78e6efaa20ffeb14803f2ba4f10615e16a6d12087697149617f0
container_name: gatus
restart: unless-stopped
environment:
# DEBUG here while a check fails for a reason the dashboard will not say.
GATUS_LOG_LEVEL: INFO
volumes:
# config.yaml is the product. Read only: Gatus never writes here.
- ./config:/config:ro
# data.db, the SQLite file holding every result and every uptime figure.
- ./data:/data
ports:
# Loopback only: no other device on the wifi can reach 8132.
- "127.0.0.1:8132:8080"
EOF
cd ~/selfhost/gatus && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. One service, one published port, two bind mounts.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule, and each is a decision. There is no hostname
to resolve. A certificate attests a public name and nothing here has one; browsers
treat http://localhost as a secure context anyway. Nothing is published beyond loopback, so no
port needs closing.
8132 is bound to 127.0.0.1, this computer only. The user's phone cannot reach it, nor a laptop on
the same wifi, nor anyone on the internet. For most apps that is a fair trade; for a status page
it is the trade, because being readable by somebody else is the one thing this software exists to
do. Confirm the binding:
```bash
grep -n '127.0.0.1' ~/selfhost/gatus/compose.yml
```
Assert: one line, `- "127.0.0.1:8132:8080"`. The checks still reach the internet normally: a
loopback binding governs what can arrive, not what the container can call.
## 7. Start and verify
Gatus runs every endpoint once at start-up rather than waiting out the first interval, so results
exist seconds after it comes up.
```bash
cd ~/selfhost/gatus
docker compose pull
docker compose up -d
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8132/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS http://localhost:8132/health; echo
curl -sSL http://localhost:8132/ | grep -c 'Health Dashboard'
curl -sS http://localhost:8132/api/v1/endpoints/statuses | grep -o '"key":"[^"]*"'
curl -sS -o /dev/null -w '%{http_code}\n' -X POST 'http://localhost:8132/api/v1/endpoints/watched_example-site/external?success=true'
```
Assert all five, and print what you received for each. The loop ends printing `200`. The health
endpoint answers `{"status":"UP"}`. The grep prints a number greater than `0`, because
`Health Dashboard` is the heading the page renders. The statuses call prints two lines,
`"key":"internal_gatus"` and `"key":"watched_example-site"`, which is step 3's configuration
loaded and checked once. The last command prints `401`: that push route is the only thing here
that writes, and it refuses a call carrying no bearer token. If any of the five misses, stop, run
`docker compose logs --tail 40 gatus`, and name the likely cause: a container that exits on its
own is step 3, because a configuration Gatus cannot parse makes it refuse to start. If
`port is already allocated` came back, find what holds 8132
(`lsof -nP -iTCP:8132 -sTCP:LISTEN`, `ss -ltnp | grep 8132` on Linux,
`netstat -ano | findstr :8132` on Windows) and stop until the user frees it. A running container
is not success.
STOP: tell the user to open http://localhost:8132, confirm they see two rows under the headings
`internal` and `watched`, and then replace `example.org` in ~/selfhost/gatus/config/config.yaml
with a site they actually care about. Do not continue until they confirm the two rows.
## 8. First backup and restore
One archive: the configuration, the results database and the compose file.
```bash
cd ~/selfhost/gatus
docker compose stop
tar -C ~/selfhost/gatus -czf ~/selfhost/gatus/backups/gatus-$(date +%F).tar.gz config data compose.yml
docker compose start
ls -lh ~/selfhost/gatus/backups/
```
Assert: the archive exists and is non-empty. Print its size. Downtime is about five seconds; the
container is stopped on purpose, because a SQLite file copied mid-write is not a backup.
That archive sits on the same disk as the data, which is not a backup, and on a laptop the disk
and the machine fail together. Ask the user for a destination that leaves this computer, a folder
their sync service watches or a USB stick, and copy it there with `cp`. In Git Bash a Windows
drive is written `/d/Backups`, not `D:\Backups`; confirm it exists before copying. Assert: the
user confirms the filename is listed there. If they have nowhere, say plainly that this install
has no backup.
To restore: `cd ~/selfhost/gatus`, `docker compose down`, `rm -rf config data`, untar the archive
there, then `docker compose up -d`. Tell the user which half matters: `config/config.yaml` is
every monitor they ever wrote, and `data/data.db` is only the history behind them. Losing the
second costs the uptime figures. Losing the first costs the product.
## 9. Updating later
New versions are listed at https://github.com/TwiN/gatus/releases. The release tag and the image
tag are the same string. Take a backup first, then edit the image line in
~/selfhost/gatus/compose.yml to the new tag and its digest:
```bash
cd ~/selfhost/gatus
docker compose pull
docker compose up -d
docker compose logs --tail 30 gatus
```
Gatus migrates the SQLite schema on the way up. Watch that log until it settles, then re-run
step 7's `/health` and statuses checks before calling the update done.
## 10. What will probably go wrong
I closed the lid on a Friday, opened the dashboard on Monday, and it told me everything had been
up all weekend. It had told me nothing. The machine was asleep, so no check ran, and a check that
never ran leaves no failed result behind: uptime is worked out from the results that exist, so a
weekend-shaped hole reads as a clean weekend rather than as a gap. That is the honest shape of a
monitor on a laptop. Turn on Docker Desktop's start-at-login setting, and after any reboot run
`cd ~/selfhost/gatus && docker compose up -d` before believing a green figure.
## 11. Out of scope
- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8132 to 0.0.0.0 so a phone on the wifi can load the page. That puts a page
listing every URL in config.yaml on every network this machine joins, with no login on it.
- Do not configure an alerting provider. Each one needs a webhook URL or a key from a service the
user signs up for, and the commented block in config.yaml is where theirs goes.
- Do not switch `storage.type` to postgres. SQLite is why this is one container and one folder to
copy.compose.local.ymlthe services, pinned · local layout35 lines
# Gatus · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# configuration ...... https://github.com/TwiN/gatus/blob/v5.36.0/README.md#configuration
# image build ........ https://github.com/TwiN/gatus/blob/v5.36.0/Dockerfile
#
# One service on the computer you are sitting at. Both paths are relative to
# ~/selfhost/gatus/, so one file works on macOS, Linux and Windows, and both
# stay bind mounts rather than named volumes, so you can open config.yaml in
# Finder or Explorer. Storage is SQLite in ./data, because upstream's default is
# memory and upstream
# says memory does not survive a restart. The config directory is mounted rather
# than the single file, since Gatus polls the loaded path for changes and
# upstream reports that binding the file hides them. No healthcheck and no
# `user:` line, because the image is built FROM scratch: no shell to run a check
# with, no user database to name a user from. No .env: no secret. Digest read
# from ghcr.io on 2026-08-06; the list covers amd64, arm64 and arm/v7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
gatus:
image: ghcr.io/twin/gatus:v5.36.0@sha256:c5f210d095fa78e6efaa20ffeb14803f2ba4f10615e16a6d12087697149617f0
container_name: gatus
restart: unless-stopped
environment:
# DEBUG here while a check fails for a reason the dashboard will not say.
GATUS_LOG_LEVEL: INFO
volumes:
# config.yaml is the product. Read only: Gatus never writes here.
- ./config:/config:ro
# data.db, the SQLite file holding every result and every uptime figure.
- ./data:/data
ports:
# Loopback only: no other device on the wifi can reach 8132.
- "127.0.0.1:8132:8080"agent-readable mirror: /self-host/pingdom.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned35 lines
# Gatus · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker deployment .. https://github.com/TwiN/gatus/blob/v5.36.0/README.md#docker
# configuration ...... https://github.com/TwiN/gatus/blob/v5.36.0/README.md#configuration
# storage ............ https://github.com/TwiN/gatus/blob/v5.36.0/README.md#storage
# image build ........ https://github.com/TwiN/gatus/blob/v5.36.0/Dockerfile
#
# One service, no database container: storage is SQLite in the /data mount,
# because upstream's default is memory and upstream says memory does not survive
# a restart. The config directory is mounted rather than the single file, since
# Gatus polls the loaded path for changes and upstream reports that binding the
# file hides them. No healthcheck and no `user:` line: the image is built FROM
# scratch and carries the binary and the CA bundle, so there is no shell to run
# a check with and no user database to name a user from. No .env: this install
# generates no secret. Digest read from ghcr.io on 2026-08-06; the manifest list
# covers amd64, arm64 and arm/v7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
gatus:
image: ghcr.io/twin/gatus:v5.36.0@sha256:c5f210d095fa78e6efaa20ffeb14803f2ba4f10615e16a6d12087697149617f0
container_name: gatus
restart: unless-stopped
environment:
# DEBUG here while a check fails for a reason the dashboard will not say.
GATUS_LOG_LEVEL: INFO
volumes:
# config.yaml is the product. Read only: Gatus never writes here.
- /srv/gatus/config:/config:ro
# data.db, the SQLite file holding every result and every uptime figure.
- /srv/gatus/data:/data
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8132.
- "127.0.0.1:8132:8080"Caddyfilethe hostname and TLS29 lines
# Gatus · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/TwiN/gatus/blob/v5.36.0/README.md#deployment and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. Everything served here
# is public on purpose: a status page nobody can open is a log file.
<DOMAIN> {
# Gatus compresses its own responses when the browser asks, so this mostly
# covers what the container hands over uncompressed.
encode zstd gzip
# About how the page is framed and referred to, not about protecting a
# session. There is no session here: no accounts, no login, no cookie.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
# 8132 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8132
}install.shthe same install, no agent196 lines
#!/usr/bin/env bash
# Gatus · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
# DOMAIN_HOST=status.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
# https://github.com/TwiN/gatus/blob/v5.36.0/README.md#configuration
# https://github.com/TwiN/gatus/blob/v5.36.0/README.md#conditions
# https://github.com/TwiN/gatus/blob/v5.36.0/README.md#storage
# https://github.com/TwiN/gatus/blob/v5.36.0/README.md#docker
# https://github.com/TwiN/gatus/blob/v5.36.0/Dockerfile
#
# No secret is generated here, because this install has none: Gatus ships no
# account, no registration form and no administration screen, and the one route
# that writes anything refuses every call without a bearer token that only the
# configuration file can declare. This one declares none.
#
# What it produces instead is a page anybody can read. Every endpoint name and
# every endpoint URL in config/config.yaml is published to whoever loads it.
# Read that file before you run this, and again every time you edit it.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/gatus}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }
# --- 1. Refuse to start on a machine that is not ready -----------------------
[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. status.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 512 ] || die "only ${avail_mb} MB of RAM available; this install wants 512 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 GB"
resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."
# --- 2. Lay the files out, configuration first -------------------------------
#
# Gatus will not start without a configuration file, and the configuration is
# the product: monitors, conditions, alerting. It is written before the
# container has ever run.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups" "$APP_DIR/config"
sudo install -d -m 750 "$APP_DIR/data"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
if [ ! -f "$APP_DIR/config/config.yaml" ]; then
cat > "$APP_DIR/config/config.yaml" <<-'GATUSCONFIG'
# Gatus · the configuration is the product. Authored by caniselfhostit from
# https://github.com/TwiN/gatus/blob/v5.36.0/README.md#configuration
#
# Every name and every URL below is printed on a page anyone can open.
storage:
type: sqlite
path: /data/data.db
endpoints:
- name: gatus
group: internal
url: "http://127.0.0.1:8080/health"
interval: 60s
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- name: status-page
group: public
url: "https://<DOMAIN>/health"
interval: 60s
conditions:
- "[STATUS] == 200"
- "[RESPONSE_TIME] < 1000"
- "[CERTIFICATE_EXPIRATION] > 240h"
- name: dns
group: public
url: "1.1.1.1"
interval: 5m
dns:
query-name: "<DOMAIN>"
query-type: "A"
conditions:
- "[DNS_RCODE] == NOERROR"
# Alerting is off. Every provider wants a webhook URL or a key from a service
# you sign up for. To turn Slack on: uncomment, paste your own webhook URL, and
# add an `alerts:` list with `- type: slack` under an endpoint.
#alerting:
# slack:
# webhook-url: "PASTE_YOUR_OWN_SLACK_WEBHOOK_URL_HERE"
GATUSCONFIG
sed -i "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/config/config.yaml"
chmod 600 "$APP_DIR/config/config.yaml"
fi
cd "$APP_DIR"
docker compose config >/dev/null
# --- 3. Caddy site block, on the host ----------------------------------------
if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-gatus"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# --- 4. Ports: two open, and 8132 is not one of them -------------------------
if command -v ufw >/dev/null 2>&1; then
echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8132 stays closed"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
fi
# --- 5. Start it -------------------------------------------------------------
#
# Gatus runs every endpoint once at start-up rather than waiting out the first
# interval, so results exist seconds after the container comes up.
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/health"
for _ in $(seq 1 24); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/health" || true)"
[ "$code" = "200" ] && break
sleep 5
done
[ "${code:-}" = "200" ] || die "/health answered ${code:-nothing}. Check: docker compose logs --tail 40 gatus"
curl -sS "https://${DOMAIN_HOST}/health" | grep -q '"status":"UP"' \
|| die "/health answered 200 without status UP. Check: docker compose logs --tail 40 gatus"
# The dashboard renders this heading, so its absence means Caddy is reaching
# something other than Gatus.
curl -sSL "https://${DOMAIN_HOST}/" | grep -q 'Health Dashboard' \
|| die "the page at https://${DOMAIN_HOST}/ does not carry the dashboard heading"
# The configuration loaded and the checks have already run once.
curl -sS "https://${DOMAIN_HOST}/api/v1/endpoints/statuses" | grep -q '"key":"public_status-page"' \
|| die "the statuses API does not list the endpoints from config/config.yaml"
# The only route in this application that writes anything must refuse a call
# carrying no bearer token.
unauth="$(curl -sS -o /dev/null -w '%{http_code}' -X POST "https://${DOMAIN_HOST}/api/v1/endpoints/public_status-page/external?success=true" || true)"
[ "$unauth" = "401" ] || die "the external push route returned ${unauth}, not 401. Stop and investigate."
# --- 6. The first backup, before day one ends --------------------------------
#
# Stopped on purpose: a SQLite file copied mid-write is not a backup. Downtime
# is about five seconds.
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose stop
sudo tar -czf "$APP_DIR/backups/gatus-${STAMP}.tar.gz" -C "$APP_DIR" config data compose.yml -C /etc/caddy Caddyfile
docker compose start
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/gatus-${STAMP}.tar.gz" ] || die "the backup archive is empty"
cat <<-DONE
Gatus is answering at https://${DOMAIN_HOST}
1. That page is public. It has no login, because it has no accounts, and
every endpoint name and URL in $APP_DIR/config/config.yaml is on it.
Open it in a private window and read it as a stranger would.
2. config/config.yaml is the product. Add monitors by editing it; Gatus
picks up the change within about thirty seconds. If the new file does
not parse, the container exits and keeps exiting, so check
cd $APP_DIR && docker compose logs --tail 20 gatus
before assuming anything else broke.
3. Alerting is off. The commented Slack block in that file is where your
own webhook URL goes, and nothing here signed you up for one.
4. Keep one free external check pointed at https://${DOMAIN_HOST} from a
service you do not run. This box cannot tell you that this box is down.
5. First backup written to $APP_DIR/backups. It is on the same disk as the
data, which is not a backup. Copy it somewhere else tonight.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Pingdom.
- One vantage point, and it cannot see itself die. Pingdom checks your site from over 100 locations; this checks it from wherever you put the container, and if that machine or its network is the thing that fails, the page that would have told you is down too. Run it somewhere other than the servers it watches, and keep one free external check pointed at it. This is the one place where paying nobody is not the right answer.
- The configuration file is the product, and that cuts both ways. Every monitor, every pass condition and every alert rule lives in one YAML file you can read, diff and keep in a note, and there is no admin screen to click instead. It also means a mis-indented line is an outage: Gatus rereads the file while it runs and exits when the new one does not parse, so the container restarts into the same broken file until you fix it.
- The status page is public, because that is what a status page is. There is no login, no account and nothing to rotate, and the flip side is that the name and the URL of every endpoint you add are published to anyone who loads the page. An internal admin URL in that file is an internal admin URL on the open web. Upstream's security.basic and security.oidc settings put the page behind a password if you would rather; this install does not use either.
- You own the alerting path, and it ships switched off. Every provider needs a webhook or a key from a service you sign up for, so the install leaves a commented Slack block and nothing live. Send yourself a deliberate failure the day you wire one up, because an untested alert channel is silence with extra steps.
- No probes in other countries, no real user monitoring, no SMS gateway and no on-call rotation. Those four are most of what the paid tiers are actually selling.
Where this came from
“Because the data is stored in memory, the data will not survive a restart.”
- Gatus stores results in memory by default and upstream states that memory storage does not survive a restart, which is why this install sets the storage type to sqlite with a path on a mounted volume. source
- The dashboard, the endpoint statuses API and the badges are served without authentication unless security.basic or security.oidc is configured, and the one route that writes anything answers 401 to a call carrying no bearer token. source
- The published image is built FROM scratch, sets PORT to 8080, and reads its configuration from config/config.yaml, which is why the compose file mounts a directory at /config and carries no healthcheck. source
- Gatus reloads its configuration while running, and the documented default when the updated file is invalid is that the application exits rather than continuing on the old configuration. source
- Pingdom sells checks run from over 100 locations worldwide, which is the one thing a single self-hosted instance cannot reproduce. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Pingdom?
Not Pingdom itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Gatus. A public status page whose entire configuration is one YAML file you own: the checks, the pass conditions and the alerting, declared once and served as a page. The install is one command: one container behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 9 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Pingdom?
Gatus. A public status page whose entire configuration is one YAML file you own: the checks, the pass conditions and the alerting, declared once and served as a page. The closest match to what Pingdom's synthetic tier is actually used for, and the honest one about the difference. Checks, pass conditions, alert rules and the public status page are declared in a single YAML file you own, so a monitor is a diff rather than a form, and the conditions go past `is it 200` to response time, JSON body fields, certificate expiry and domain expiry. What it cannot do is watch from a hundred cities, and it cannot report the outage that takes down the machine it runs on, which is why the page tells you to keep one free external check pointed at it. Also evaluated and ranked below it: Uptime Kuma. The prompt on this page installs Gatus only. Gatus is Apache-2.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Pingdom?
512 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. Gatus itself is free and Apache-2.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Pingdom Synthetic Monitoring · 10 uptime checks, $18/mo — $216 a year, a metered rate, not a whole bill.
How hard is it really?
ONE COMMAND — under 10 minutes. The rule that produced that verdict: one container, no database, no outside integration, at most one secret. Nothing to negotiate with anyone else, nothing to back up separately, at most one secret to generate. This is the case where the compose file honestly is the whole install. The tier is derived from seven countable facts about the Gatus install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Gatus on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same Gatus on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: A status page exists so other people can read it, and this one answers only at http://localhost:8132, so nobody you would send it to can open it; the checks also run only while the computer is awake, and a laptop asleep overnight records no outage because it records nothing. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.