# Can I self-host Pingdom?

**YES** — it's called Gatus. ONE COMMAND setup · ~9 minutes to running · 512 MB RAM minimum · $18/mo you stop paying ($216/yr on the Synthetic Monitoring · 10 uptime checks plan) — a metered rate, not a whole bill.

Gatus authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/pingdom/

## Install prompt (Claude Code)

````text
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.
````

## Chat fallback

````text
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.
````

## Local install prompt (your own computer, no server)

````text
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.
````

## docker-compose.yml

```yaml
# 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"
```

## compose.local.yml

```yaml
# 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"
```

## Caddyfile

```text
# 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.sh

```bash
#!/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.

DONE
```

## Also evaluated

Ranked below Gatus for this swap. The prompts above install Gatus only.

- **Uptime Kuma** — 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 page this mirrors: https://caniselfhostit.com/self-host/pingdom/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
