Can I self-host UptimeRobot?

YES · ONE COMMAND— setup effort 1 of 4

YES — it's called Uptime Kuma. It takes one prompt, a 512 MB VPS, and about 8 minutes. That is $10 a month you stop paying UptimeRobot — $120 a year on the Solo plan.

Why people pay for UptimeRobot

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.

UptimeRobot checks your site from somewhere that is not your site — which is the one thing a monitor you host yourself cannot honestly do. The paid tiers buy shorter intervals, more monitors, and an alerting path that keeps working on the morning your own server is the thing that is down.

UptimeRobot plans and list prices
PlanList priceWhat it buys
Freefree50 monitors, 5-minute checks.
Solothe plan this page prices against$10/mo10 monitors, 60-second checks. $9/mo if paid annually.
Team$34/mo100 monitors, 30-second checks. $29/mo if paid annually.
Scale$64/mo200 to 500 monitors, 15-second checks. $54/mo if paid annually.
Enterprisequote onlyQuote only — custom monitor counts and faster intervals.

Vendor list prices in USD, read from the pricing page on 2026-08-05 · confidence: high

Replaced by Uptime Kuma

One project, named before the prompt, so you know what you are about to install.

Uptime monitoring and status pages from one container, with no monitor quota and no per-check billing.

Matches the paid feature set that people actually use — sub-minute checks, unlimited monitors, status pages, and notifications to the twenty-odd services you already have — from one container with no database to operate. It cannot replace the outside-your-network vantage point, which is the honest limit and the reason the page recommends keeping a free external monitor pointed at it.

The swap

You're paying

UptimeRobot

$10/mo · $120/yr

is replaced by

You'd run

Uptime Kuma

ONE COMMAND · ~8 min to running · 512 MB RAM

UptimeRobot Solo · vendor list price · checked 2026-08-05 · source

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
~8 minunder 10 minutes, through the first backup

The prompt

Two paths to the same Uptime Kuma: 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.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

254 lines · 11,075 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

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 Uptime Kuma 2.5.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. Uptime Kuma needs 512 MB of RAM available and
5 GB free on /srv, and the 2.5.0 image covers amd64 and arm64.

```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 RAM is under 512 MB or disk under 5 GB, print both numbers and stop. If `dig +short` prints
nothing, print that and stop: Caddy cannot certify a hostname that does not resolve.

One thing to say out loud to the user before installing a monitor on the machine it will be
monitoring: this box cannot tell them it is down. If they have another server, this belongs on
the other one.

## 2. Layout

The image runs as the `node` user, uid 1000, so the data directory belongs to 1000 and not to
the login user. `db-config.json` is written now, before the first boot, because it is what
picks the database: with that file present Uptime Kuma uses SQLite and skips its database
setup screen entirely.

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/uptime-kuma /srv/uptime-kuma/backups
sudo install -d -m 750 -o 1000 -g 1000 /srv/uptime-kuma/data
printf '{\n    "type": "sqlite"\n}\n' | sudo tee /srv/uptime-kuma/data/db-config.json >/dev/null
sudo chown 1000:1000 /srv/uptime-kuma/data/db-config.json
ls -la /srv/uptime-kuma/data
```

Assert: `ls -la` shows `db-config.json` owned by `1000`. Nothing is written outside
/srv/uptime-kuma, and the data directory is on local disk, because SQLite on a network mount
corrupts quietly and weeks later.

## 3. Secrets

No secret is generated for this install, and there is no `.env` file. Uptime Kuma's only
credential is the administrator account, and it is created in a browser at step 7 rather than
written into a file here. That is why this block has nothing to run.

Say one thing to the user now: between the container starting and them creating that account,
the setup screen is open to whoever reaches the hostname first. Step 7 is written to make that
window as short as it can be, and it is a hard stop for exactly that reason.

## 4. compose.yml

```bash
cat > /srv/uptime-kuma/compose.yml <<'EOF'
# Uptime Kuma · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   compose shape ...... https://github.com/louislam/uptime-kuma/blob/master/compose.yaml
#   install notes ...... https://github.com/louislam/uptime-kuma/wiki/%F0%9F%94%A7-How-to-Install
#   reverse proxy ...... https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy
#
# One container. There is no Caddy service here: Prompt Zero already runs Caddy
# under systemd on the host, and a second one in a container would fight it for
# 80 and 443. Upstream pins the floating `2` tag and publishes 3001 on every
# interface; this file pins the exact release and binds to loopback instead. The
# image runs as the node user (uid 1000), hence the ownership in step 2. Tag and
# digest are the 2.5.0 release read from Docker Hub on 2026-08-05, for
# linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  uptime-kuma:
    image: louislam/uptime-kuma:2.5.0@sha256:a8610b3b4c38077922ba51b036691e06887d7cefd91fe620fd3d6d23d03dc240
    container_name: uptime-kuma
    restart: unless-stopped
    volumes:
      # kuma.db and db-config.json both live here. Local disk only: SQLite
      # needs real POSIX file locks, and a network mount corrupts it quietly.
      - /srv/uptime-kuma/data:/app/data
    ports:
      # Loopback only. The Caddy that Prompt Zero installed on the host is the
      # only thing that can reach this port, and 8091 never enters the firewall.
      - "127.0.0.1:8091:3001"
EOF
cd /srv/uptime-kuma && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. The container serves on 3001 inside itself and 8091 is bound
to 127.0.0.1 on the host, so the only route in is Caddy. Do not add a Caddy service to this
file: Caddy is already running under systemd on this box, and a container claiming 80 and 443
would fail to start and take every other site down with it.

## 5. Caddy and TLS

Append the block below with `<DOMAIN>` replaced by the real hostname. Copy the file first: a
syntax error takes down every site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-uptime-kuma
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Uptime Kuma · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy 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. Caddy runs under systemd
# on the host. There is no Caddy container anywhere in this project.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# The dashboard is a WebSocket application. Caddy negotiates the upgrade on
	# its own, so there are no Upgrade or Connection headers to set by hand,
	# which is the step every nginx guide spends a paragraph on.
	#
	# 8091 is the loopback port compose publishes; it is never in the firewall.
	reverse_proxy 127.0.0.1:8091
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: both exit 0. If validate fails, restore /etc/caddy/Caddyfile.before-uptime-kuma,
reload, and report what it objected to. Caddy gets the certificate on the first request and
renews it with no cron job.

## 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, 443/udp
is HTTP/3. 8091 stays closed: bound to 127.0.0.1, a rule for it would cover traffic that cannot
arrive, and if it appears there a previous run left it, which `sudo ufw delete allow 8091`
fixes. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and 443/udp, and
no rule for 8091.

## 7. Start and verify

The image's own health check allows a three-minute start period, so give the first boot time
before treating anything as broken.

```bash
cd /srv/uptime-kuma
docker compose pull
docker compose up -d
sleep 60
docker inspect --format '{{.State.Health.Status}}' uptime-kuma
curl -sS https://<DOMAIN>/setup-database-info
echo
curl -sSL https://<DOMAIN>/ | grep -ci 'uptime kuma'
```

Assert, all three: `docker inspect` prints `healthy`, the JSON line contains `"needSetup":false`
because step 2 already chose SQLite, and the last prints a number greater than `0`. Print what
you received for each. If health is `starting`, wait 60 seconds and check again. If anything
still misses, stop, run `docker compose logs --tail 40 uptime-kuma`, and name the likely
earlier step. A running container is not success.

The first screen at https://<DOMAIN> is a form asking for a username and a password to create
the administrator account. Until it is submitted, anyone who loads that page can submit it.

STOP: tell the user to open https://<DOMAIN> right now, create the administrator account, and
save the password in their password manager. Wait until they confirm.

Then have them prove it closed: ask them to open https://<DOMAIN> in a private window and
confirm they see a sign-in form and no create-account fields. Assert: they confirm that in
words. Do not report success on the strength of the container being up.

## 8. First backup and restore

Take the backup now, before the user adds a monitor. Stop first: a SQLite file copied mid-write
is not a backup.

```bash
cd /srv/uptime-kuma
docker compose stop
sudo tar -C /srv/uptime-kuma -czf /srv/uptime-kuma/backups/uptime-kuma-$(date +%F).tar.gz data
docker compose start
ls -lh /srv/uptime-kuma/backups/
```

Assert: the archive exists and is non-empty. Print its size. `data` is the whole install: there
is no `.env` here, and `data/kuma.db` holds the monitors, the account and every heartbeat ever
recorded. A backup on the same disk is not a backup, so run this from the user's machine:

```bash
mkdir -p ~/backups/uptime-kuma
scp vps:/srv/uptime-kuma/backups/*.tar.gz ~/backups/uptime-kuma/
```

To restore: `docker compose down`, `sudo rm -rf /srv/uptime-kuma/data`,
`sudo tar -C /srv/uptime-kuma -xzf` the archive, then `docker compose up -d`. Those four
commands are the whole disaster plan. Tell the user that heartbeat history is kept forever by
default, so this archive grows with the number of monitors times the check interval, and the
retention setting in the interface is the lever if the disk starts filling.

## 9. Updating later

New versions are at https://github.com/louislam/uptime-kuma/releases. Take a backup first, then
edit the image line in /srv/uptime-kuma/compose.yml to the new tag and digest. Uptime Kuma
migrates its own database on the next boot, so wait for the health check to go green before
calling this done.

```bash
cd /srv/uptime-kuma
docker compose pull
docker compose up -d
docker compose logs --tail 20 uptime-kuma
```

## 10. What will probably go wrong

The alert that never arrives. I set up a monitor, watched it go green, and assumed the whole
thing worked, including the notification channel I had configured and never fired. It had a
typo in the webhook URL, and I found out three weeks later when a real outage produced silence.
Tell the user to do this on day one, not later: point a second monitor at a hostname that does
not exist, wait for it to go red, and confirm the alert lands on their phone. An untested alert
channel is not an alert channel, and this failure is invisible until the day it matters.

## 11. Out of scope

- Do not add a Caddy container to the compose file. Caddy is already running under systemd on
  this box, and a second one would fight it for 80 and 443.
- Do not configure MariaDB. `db-config.json` picks SQLite, which is why this is one container.
- Do not configure notification channels. Every one of them is an account or a token somewhere
  else, and the user picks those in the interface.
- Do not publish 3001 on the host or open it in the firewall. Caddy is the only way in.
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 Uptime Kuma 2.5.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.

One thing to sit with before you start: a monitor running on this server cannot tell you when
this server is down. If you have a second machine anywhere, this belongs on that one, and you
should keep a free external check pointed at whichever box ends up hosting it.

## 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 address on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it at your DNS
provider, wait a minute, and run `dig +short <DOMAIN>` again. Caddy cannot get a certificate
for a hostname that does not resolve, and failed attempts count against a rate limit you cannot
see.

## 2. Layout

The image runs as the `node` user, uid 1000, so `data` belongs to 1000 and not to you. The
`db-config.json` written here is what picks the database: with that file in place Uptime Kuma
uses SQLite and never shows you its database setup screen.

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/uptime-kuma /srv/uptime-kuma/backups
sudo install -d -m 750 -o 1000 -g 1000 /srv/uptime-kuma/data
printf '{\n    "type": "sqlite"\n}\n' | sudo tee /srv/uptime-kuma/data/db-config.json >/dev/null
sudo chown 1000:1000 /srv/uptime-kuma/data/db-config.json
ls -la /srv/uptime-kuma/data
```

You should see: `db-config.json` owned by `1000`, a few dozen bytes.

If you do not: a file owned by `root` means the `chown` line did not run, and the container
will refuse to write beside it. Run that line again on its own. This directory has to be on
local disk: SQLite needs real POSIX file locks, and a network mount corrupts the database
quietly, weeks later.

## 3. Secrets

There is nothing to generate and no `.env` file. Uptime Kuma has one credential, the
administrator account, and you create it in a browser at step 7.

Between the container starting and that account existing, the setup form is open to whoever
reaches your hostname first. Step 7 is written to make that window short, which is why it asks
you to stop reading and go create the account the moment the checks pass.

Nothing in this guide asks you to paste a credential into this chat window. Do not paste the
administrator password, or the output of any command containing it, at any point.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/uptime-kuma/compose.yml <<'EOF'
# Uptime Kuma · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   compose shape ...... https://github.com/louislam/uptime-kuma/blob/master/compose.yaml
#   install notes ...... https://github.com/louislam/uptime-kuma/wiki/%F0%9F%94%A7-How-to-Install
#   reverse proxy ...... https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy
#
# One container. There is no Caddy service here: Prompt Zero already runs Caddy
# under systemd on the host, and a second one in a container would fight it for
# 80 and 443. Upstream pins the floating `2` tag and publishes 3001 on every
# interface; this file pins the exact release and binds to loopback instead. The
# image runs as the node user (uid 1000), hence the ownership in step 2. Tag and
# digest are the 2.5.0 release read from Docker Hub on 2026-08-05, for
# linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  uptime-kuma:
    image: louislam/uptime-kuma:2.5.0@sha256:a8610b3b4c38077922ba51b036691e06887d7cefd91fe620fd3d6d23d03dc240
    container_name: uptime-kuma
    restart: unless-stopped
    volumes:
      # kuma.db and db-config.json both live here. Local disk only: SQLite
      # needs real POSIX file locks, and a network mount corrupts it quietly.
      - /srv/uptime-kuma/data:/app/data
    ports:
      # Loopback only. The Caddy that Prompt Zero installed on the host is the
      # only thing that can reach this port, and 8091 never enters the firewall.
      - "127.0.0.1:8091:3001"
EOF
cd /srv/uptime-kuma && 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/uptime-kuma/compose.yml` and paste the block again in one go.

If you have seen an Uptime Kuma compose file elsewhere with a `caddy` service in it, do not
merge the two. Caddy is already running under systemd on this box, and a container claiming 80
and 443 would fail to start and take every other site with it.

## 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-uptime-kuma
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Uptime Kuma · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy 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. Caddy runs under systemd
# on the host. There is no Caddy container anywhere in this project.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# The dashboard is a WebSocket application. Caddy negotiates the upgrade on
	# its own, so there are no Upgrade or Connection headers to set by hand,
	# which is the step every nginx guide spends a paragraph on.
	#
	# 8091 is the loopback port compose publishes; it is never in the firewall.
	reverse_proxy 127.0.0.1:8091
}
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-uptime-kuma /etc/caddy/Caddyfile`,
reload, and paste again, checking that the blank line from the second command really landed.
Caddy asks Let's Encrypt for the certificate on the first request to your hostname and renews
it on its own, so there is nothing to schedule.

## 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 `8091` or `3001`.

If you do not: a rule for either port from an earlier attempt should go, with
`sudo ufw delete allow 3001`. 8091 is bound to 127.0.0.1 by the compose file, so nothing
outside the machine can reach it and a firewall rule for it would cover traffic that cannot
arrive.

## 7. Start and verify

The image's own health check allows a three-minute start period, so the first boot is slower
than you expect. That is not a failure.

```bash
cd /srv/uptime-kuma
docker compose pull
docker compose up -d
sleep 60
docker inspect --format '{{.State.Health.Status}}' uptime-kuma
curl -sS https://<DOMAIN>/setup-database-info
echo
curl -sSL https://<DOMAIN>/ | grep -ci 'uptime kuma'
```

You should see: `healthy`, then a line of JSON containing `"needSetup":false`, then a number
greater than `0`.

If you do not: `starting` is not a failure yet, so wait 60 seconds and run the `docker inspect`
line again. `"needSetup":true` means `db-config.json` did not take, so go back to step 2 and
check its ownership. `000` or `502` from curl means the certificate is not there yet, so run
`sudo journalctl -u caddy -n 30`.

A container listed in `docker ps` is not proof of anything. The three checks above are.

Now open https://<DOMAIN> and create your administrator account. Do it before you make coffee:
until it exists, anyone who loads that page can create it instead. Then open the same URL in a
private window.

You should see: a sign-in form, with no create-account fields.

If you do not: a create-account form in the private window means your account was not saved.
Go back and finish it before you do anything else.

## 8. First backup and restore

Do this before you add a monitor, so you find out now whether it works. The stop matters: a
SQLite file copied mid-write is not a backup.

```bash
cd /srv/uptime-kuma
docker compose stop
sudo tar -C /srv/uptime-kuma -czf /srv/uptime-kuma/backups/uptime-kuma-$(date +%F).tar.gz data
docker compose start
ls -lh /srv/uptime-kuma/backups/
```

You should see: one `.tar.gz` file, a few hundred kilobytes on a fresh install.

If you do not: `tar: data: Cannot open` means the `cd` did not happen. A size of `45` bytes
means tar wrote an empty archive because the paths were wrong, so check
`sudo ls /srv/uptime-kuma/data` before you trust it.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not on
the server:

```bash
mkdir -p ~/backups/uptime-kuma
scp vps:/srv/uptime-kuma/backups/*.tar.gz ~/backups/uptime-kuma/
```

You should see: one file copied, and the same file listed by `ls -lh ~/backups/uptime-kuma/`.

If you do not: `Permission denied (publickey)` means you ran it on the server by mistake. The
`vps:` prefix only means something on your own machine.

Now prove the restore, because a backup you have never restored is a guess:

```bash
cd /srv/uptime-kuma
docker compose down
sudo rm -rf /srv/uptime-kuma/data
sudo tar -C /srv/uptime-kuma -xzf /srv/uptime-kuma/backups/uptime-kuma-$(date +%F).tar.gz
docker compose up -d
```

You should see: `Created` and `Started`, then after a minute a sign-in page at https://<DOMAIN>
that still accepts your administrator account.

If you do not: a page that has turned back into a create-account form means the archive did not
contain `data/kuma.db`. Stop and go back to the tar step. Those four commands are the whole
disaster plan, and you have now run them once.

Heartbeat history is kept forever by default, so this archive grows with the number of monitors
times how often they check. On a small disk that is the thing that fills it, and the retention
setting in the interface is the lever.

## 9. Updating later

New versions are at https://github.com/louislam/uptime-kuma/releases. Take a backup first, then
edit the `image:` line in /srv/uptime-kuma/compose.yml to the new tag and its digest.

```bash
cd /srv/uptime-kuma
docker compose pull
docker compose up -d
docker compose logs --tail 20 uptime-kuma
```

You should see: `Recreated`, then startup and migration lines, then no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Uptime Kuma
migrates its own database on the next boot, so wait for
`docker inspect --format '{{.State.Health.Status}}' uptime-kuma` to print `healthy` before you
call the update done.

## 10. What will probably go wrong

The alert that never arrives. I set up a monitor, watched it go green, and assumed the whole
thing worked, including the notification channel I had configured and never fired. It had a
typo in the webhook URL, and I found out three weeks later when a real outage produced silence.
Do this on day one: point a second monitor at a hostname that does not exist, wait for it to go
red, and confirm the alert lands on your phone. An untested alert channel is not an alert
channel, and this failure is invisible until the day it matters.

## 11. Out of scope

- Do not add a Caddy container to the compose file. Caddy already runs under systemd here.
- Do not configure MariaDB. `db-config.json` picks SQLite, which is why this is one container.
- Do not configure notification channels while installing. Each is an account or a token
  somewhere else, and you pick those in the interface afterwards.
- Do not publish 3001 on the host or open it in the firewall. Caddy is the only way in.

299 lines · 14,989 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

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 Uptime Kuma 2.5.0 on this computer, reachable at http://localhost:8091 from this
computer and nowhere else, with everything it keeps under ~/selfhost/uptime-kuma/.

## 1. Preflight

Find out which operating system this is first. Steps 2, 3 and 8 branch on it.

```bash
uname -s
case "$(uname -s)" in
  Darwin) sysctl -n hw.memsize | awk '{printf "%d MB of RAM installed\n", $1/1048576}' ;;
  Linux) free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory" ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, and `MINGW` or `MSYS` is Windows under Git Bash.

Uptime Kuma needs 512 MB of RAM and 5 GB free on the home disk. The 2.5.0 image covers amd64
and arm64, so Apple Silicon and Intel are both fine.

Free disk under 5 GB is a stop on any OS. For RAM, only the Linux line measures the floor:
under 512 MB available, print both numbers and stop. macOS and Windows print installed RAM
instead (bytes on Windows, divide by 1048576), which any machine running Docker Desktop clears;
there the 512 MB applies to Docker Desktop's own allocation, so have the user check Settings,
Resources and stop if it shows less. Do not install and hope.

Say this to the user before installing anything, with nothing left out. A monitor on a machine
that sleeps is a monitor that sleeps: when the lid closes the checks stop and nothing is
watching until it wakes. This computer also cannot alert them that it is itself off: a monitor
that is down produces the same silence as one with nothing to report. If they want checks that
keep running overnight, this belongs on a machine that stays on, with one free external check
pointed at it. That is not a reason to stop, it is what they are choosing.

## 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:

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

  Say one sentence to the user: membership of the `docker` group is root-equivalent on this
  machine, and the group change lands at their next login, so they may need to log out first.
- 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

Everything lives in one directory under the home directory. `db-config.json` is written before
the first boot because it picks the database: with that file present Uptime Kuma uses SQLite
and skips its database setup screen.

```bash
mkdir -p ~/selfhost/uptime-kuma/data ~/selfhost/uptime-kuma/backups
printf '{\n    "type": "sqlite"\n}\n' > ~/selfhost/uptime-kuma/data/db-config.json
ls -la ~/selfhost/uptime-kuma/data
```

On Linux only, `./data` is a real directory on the real filesystem and the image runs as uid
1000, so hand it over:

```bash
sudo chown -R 1000:1000 ~/selfhost/uptime-kuma/data
```

Do not run that on macOS or Windows. Docker Desktop runs the engine in a virtual machine and
rewrites ownership across its file share, so the container already sees itself as the owner and
a host `chown` changes nothing it can observe.

Assert: `ls -la` lists `db-config.json`, and on Linux it belongs to `1000` after the chown.
Keep this directory on the local disk and out of any folder a sync service watches: SQLite
needs real POSIX file locks, and a synced or networked folder corrupts it quietly, weeks
later. Backups are the exception, and step 8 uses one.

## 4. Secrets

Nothing is generated here and there is no `.env` file. Uptime Kuma's only credential is the
administrator account, and the user creates it in a browser at step 7, so this block runs no
commands.

Say two things to the user. First: between the container starting and that account existing,
the setup form is open to anyone with a session on this computer, which is why step 7 makes
that window short and stops there. Second, on Windows: mode bits on NTFS are advisory, so no
`chmod` protects anything here; their own account is the real boundary on a single-user
machine, and everything this install writes sits in their home directory.

## 5. compose.yml

```bash
cat > ~/selfhost/uptime-kuma/compose.yml <<'EOF'
# Uptime Kuma · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   compose shape ...... https://github.com/louislam/uptime-kuma/blob/master/compose.yaml
#   install notes ...... https://github.com/louislam/uptime-kuma/wiki/%F0%9F%94%A7-How-to-Install
#   image details ...... https://github.com/louislam/uptime-kuma/blob/master/docker/dockerfile
#
# One container, on the computer the user is sitting at. No Caddy and no
# certificate: nothing outside this machine can reach 8091, so there is nothing
# to certify. This file sits in ~/selfhost/uptime-kuma/ and its paths are
# relative to it, which is what lets one file work on macOS, Linux and Windows.
# Upstream pins the floating `2` tag and publishes 3001 on every interface; this
# file pins the release and binds to loopback. The image runs as the node user
# (uid 1000), hence the Linux-only chown in step 3. Tag and digest are the 2.5.0
# release read from Docker Hub on 2026-08-05, for linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  uptime-kuma:
    image: louislam/uptime-kuma:2.5.0@sha256:a8610b3b4c38077922ba51b036691e06887d7cefd91fe620fd3d6d23d03dc240
    container_name: uptime-kuma
    restart: unless-stopped
    volumes:
      # kuma.db and db-config.json live here. Local disk only: SQLite needs real
      # POSIX file locks, so a network mount or a synced folder corrupts it.
      - ./data:/app/data
    ports:
      # Loopback only. No other device on this network can reach 8091, not even
      # the user's phone.
      - "127.0.0.1:8091:3001"
EOF
cd ~/selfhost/uptime-kuma && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. The container serves 3001 inside itself; 8091 is the host
port bound to 127.0.0.1, the same one the server path uses.

## 6. Nothing is public

Nothing here is reachable from outside this computer, and no step in this prompt changes that.

- The published port is `127.0.0.1:8091`, which means this machine only. Another laptop on the
  same wifi cannot reach it, and neither can the user's phone. That is this path, not a defect
  in it.
- No domain, no DNS record to wait for, no certificate to issue, no firewall rule to add: there
  is no hostname to certify and nothing listening on an address another machine could route to.
  The server path's DNS wait, TLS step and ufw block all fall away.
- Browsers treat `http://localhost` as a secure context, so the dashboard works over plain HTTP
  without the warnings a real hostname would earn.
- This install sets no base URL and the compose file carries no hostname. If the user later
  fills a base-URL field, http://localhost:8091 is the only value that resolves here, and links
  built from it are dead on every other device.
- The checks go out from this computer, over whatever network it is on. A site that is up for
  everyone else but blocked on this cafe wifi is recorded as down: a true answer to a narrower
  question than a rented server answers.

## 7. Start and verify

The image carries a health check with a three-minute start period, so the first boot takes
longer than it looks like it should. That is not a failure yet.

```bash
cd ~/selfhost/uptime-kuma
docker compose pull
docker compose up -d
sleep 60
docker inspect --format '{{.State.Health.Status}}' uptime-kuma
curl -sS http://localhost:8091/setup-database-info
echo
curl -sSL http://localhost:8091/ | grep -ci 'uptime kuma'
```

Assert, all three: `docker inspect` prints `healthy`, the JSON line contains `"needSetup":false`
because step 3 chose SQLite, and the last command prints a number greater than `0`.
Print what you received for each. If health is `starting`, wait 60 seconds and check again. If
anything still misses, stop, run `docker compose logs --tail 40 uptime-kuma`, and name the
earlier step that likely caused it. A running container is not success.

The first screen at http://localhost:8091 is a form asking for a username and password to
create the administrator account. Until it is submitted, anyone at this computer can submit it.

STOP: tell the user to open http://localhost:8091 now, create the administrator account, and
save the password in their password manager. Wait until they confirm.

Then have them prove it closed: ask them to open http://localhost:8091 in a private window and
confirm a sign-in form with no create-account fields. Assert: they confirm that in words. Do
not report success on the container being up.

## 8. First backup and restore

Take the backup now, before the user adds a monitor. Stop the container first: a SQLite file
copied mid-write is not a backup.

```bash
cd ~/selfhost/uptime-kuma
docker compose stop
tar -czf backups/uptime-kuma-$(date +%F).tar.gz data
docker compose start
ls -lh backups/
```

Assert: `ls -lh` shows the archive non-empty, and `tar -tzf` on it lists `data/kuma.db`. Print
its size. On Linux, if `tar` prints `Cannot open: Permission denied`, the login user is not uid
1000 and step 3 handed `data` to 1000: rerun the line with `sudo` and tell the user the archive
belongs to root.

`data` is the whole install: no `.env` here, and `data/kuma.db` holds the monitors, the
administrator account and every heartbeat ever recorded.

A backup on the same disk as the data is not a backup, and on one computer the disk and the
machine fail together. Get a copy off this machine now, and ask first: which folder does a sync
service already watch, iCloud Drive, OneDrive, Dropbox, Syncthing? If they sync nothing, have
them plug in a USB stick, under /Volumes on macOS, usually /media on Linux, a drive letter such
as /d in Git Bash. Confirm the answer with `ls -d` on it, then `cp` the archive there and print
the result. Do not guess a path: `~/Dropbox` is absent on most machines and the copy fails.

To restore, run `ls -lh backups/`, have the user name the archive, and put that exact filename
in both `ARCHIVE` slots. Nothing is deleted until `tar -tzf` has read the archive through, so a
wrong name costs a message, not the install:

```bash
cd ~/selfhost/uptime-kuma
tar -tzf backups/ARCHIVE >/dev/null && docker compose down && rm -rf data && tar -xzf backups/ARCHIVE && docker compose up -d
```

That one line is the whole disaster plan. On Linux, if step 3's chown ran against a login user
that is not uid 1000, the `rm -rf` and the `tar` need `sudo` too. Tell the user that heartbeat
history is kept forever by default, so this archive grows with the monitor count times the
check interval; the retention setting is the lever if the disk fills.

## 9. Updating later

New versions are listed at https://github.com/louislam/uptime-kuma/releases. Take a backup
first, then edit the image line in ~/selfhost/uptime-kuma/compose.yml to the new tag and
digest. Uptime Kuma migrates its own database on the next boot, so wait for the health check to
go green before calling this done.

```bash
cd ~/selfhost/uptime-kuma
docker compose pull
docker compose up -d
docker compose logs --tail 20 uptime-kuma
```

## 10. What will probably go wrong

Docker Desktop, after a reboot. The compose file says `restart: unless-stopped`, and I read
that as a promise the container comes back on its own. It does not on macOS or Windows: nothing
comes back until Docker Desktop itself is running, and it starts at sign-in, sometimes only
when the user opens it. I rebooted, went to lunch, and came back to a dashboard missing two
hours with no error anywhere in it, because a monitor that was not running produces the same
silence as one with nothing to report. Tell the user to turn on Docker Desktop's
start-at-login setting, and to read any flat gap in the graphs as this until ruled out.

## 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 configure MariaDB. `db-config.json` picks SQLite, which is why this is one container.
- Do not configure notification channels. Each one is an account or a credential somewhere
  else, and the user picks those in the interface afterwards.
- Do not rebind 8091 to this machine's network address so a phone can reach the dashboard. That
  puts a sign-in page on every network this computer joins, in exchange for a glance.
compose.local.ymlthe services, pinned · local layout30 lines

authored from upstream docs, never pasted · 1,681 bytes

# Uptime Kuma · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   compose shape ...... https://github.com/louislam/uptime-kuma/blob/master/compose.yaml
#   install notes ...... https://github.com/louislam/uptime-kuma/wiki/%F0%9F%94%A7-How-to-Install
#   image details ...... https://github.com/louislam/uptime-kuma/blob/master/docker/dockerfile
#
# One container, on the computer the user is sitting at. No Caddy and no
# certificate: nothing outside this machine can reach 8091, so there is nothing
# to certify. This file sits in ~/selfhost/uptime-kuma/ and its paths are
# relative to it, which is what lets one file work on macOS, Linux and Windows.
# Upstream pins the floating `2` tag and publishes 3001 on every interface; this
# file pins the release and binds to loopback. The image runs as the node user
# (uid 1000), hence the Linux-only chown in step 3. Tag and digest are the 2.5.0
# release read from Docker Hub on 2026-08-05, for linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  uptime-kuma:
    image: louislam/uptime-kuma:2.5.0@sha256:a8610b3b4c38077922ba51b036691e06887d7cefd91fe620fd3d6d23d03dc240
    container_name: uptime-kuma
    restart: unless-stopped
    volumes:
      # kuma.db and db-config.json live here. Local disk only: SQLite needs real
      # POSIX file locks, so a network mount or a synced folder corrupts it.
      - ./data:/app/data
    ports:
      # Loopback only. No other device on this network can reach 8091, not even
      # the user's phone.
      - "127.0.0.1:8091:3001"

agent-readable mirror: /self-host/uptimerobot.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, pinned29 lines

authored from upstream docs, never pasted · 1,589 bytes

# Uptime Kuma · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   compose shape ...... https://github.com/louislam/uptime-kuma/blob/master/compose.yaml
#   install notes ...... https://github.com/louislam/uptime-kuma/wiki/%F0%9F%94%A7-How-to-Install
#   reverse proxy ...... https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy
#
# One container. There is no Caddy service here: Prompt Zero already runs Caddy
# under systemd on the host, and a second one in a container would fight it for
# 80 and 443. Upstream pins the floating `2` tag and publishes 3001 on every
# interface; this file pins the exact release and binds to loopback instead. The
# image runs as the node user (uid 1000), hence the ownership in step 2. Tag and
# digest are the 2.5.0 release read from Docker Hub on 2026-08-05, for
# linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  uptime-kuma:
    image: louislam/uptime-kuma:2.5.0@sha256:a8610b3b4c38077922ba51b036691e06887d7cefd91fe620fd3d6d23d03dc240
    container_name: uptime-kuma
    restart: unless-stopped
    volumes:
      # kuma.db and db-config.json both live here. Local disk only: SQLite
      # needs real POSIX file locks, and a network mount corrupts it quietly.
      - /srv/uptime-kuma/data:/app/data
    ports:
      # Loopback only. The Caddy that Prompt Zero installed on the host is the
      # only thing that can reach this port, and 8091 never enters the firewall.
      - "127.0.0.1:8091:3001"
Caddyfilethe hostname and TLS28 lines

authored from upstream docs, never pasted · 983 bytes

# Uptime Kuma · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy 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. Caddy runs under systemd
# on the host. There is no Caddy container anywhere in this project.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# The dashboard is a WebSocket application. Caddy negotiates the upgrade on
	# its own, so there are no Upgrade or Connection headers to set by hand,
	# which is the step every nginx guide spends a paragraph on.
	#
	# 8091 is the loopback port compose publishes; it is never in the firewall.
	reverse_proxy 127.0.0.1:8091
}
install.shthe same install, no agent149 lines

authored from upstream docs, never pasted · 6,479 bytes

#!/usr/bin/env bash
# Uptime Kuma · 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/louislam/uptime-kuma/blob/master/compose.yaml
#   https://github.com/louislam/uptime-kuma/wiki/%F0%9F%94%A7-How-to-Install
#   https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy
#   https://caddyserver.com/docs/automatic-https
#
# Nothing is generated here. Uptime Kuma's only credential is the administrator
# account, and you create it in a browser at step 6. There is no .env file.
#
# Caddy runs under systemd on this host, installed by Prompt Zero. This script
# appends one site block to /etc/caddy/Caddyfile and starts no proxy container.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/uptime-kuma}"
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."

echo "==> reminder: a monitor on this box cannot tell you this box is down."

# --- 2. Lay the files out ----------------------------------------------------
#
# The image runs as the node user, uid 1000. db-config.json is written before
# the first boot because it is what picks the database: with it present, Uptime
# Kuma uses SQLite and skips its database setup screen.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 750 -o 1000 -g 1000 "$APP_DIR/data"
if [ ! -f "$APP_DIR/data/db-config.json" ]; then
	printf '{\n    "type": "sqlite"\n}\n' | sudo tee "$APP_DIR/data/db-config.json" >/dev/null
	sudo chown 1000:1000 "$APP_DIR/data/db-config.json"
fi
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

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-uptime-kuma"
	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 8091 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; 8091 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 and prove it works ------------------------------------------
#
# The image's health check allows a three-minute start period.

docker compose pull
docker compose up -d

echo "==> waiting for the container's own health check to go green"
for _ in $(seq 1 48); do
	state="$(docker inspect --format '{{.State.Health.Status}}' uptime-kuma 2>/dev/null || echo starting)"
	[ "$state" = "healthy" ] && break
	sleep 5
done
[ "${state:-}" = "healthy" ] || die "container health is ${state:-unknown}. Check: docker compose logs --tail 40 uptime-kuma"

echo "==> waiting for https://${DOMAIN_HOST}/ (Caddy is getting a certificate)"
for _ in $(seq 1 30); do
	code="$(curl -sSL -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/" || true)"
	[ "$code" = "200" ] && break
	sleep 5
done
[ "${code:-}" = "200" ] || die "https://${DOMAIN_HOST}/ answered ${code:-nothing}. Check: sudo journalctl -u caddy -n 30"

curl -sS "https://${DOMAIN_HOST}/setup-database-info" | grep -q '"needSetup":false' \
	|| die "the database setup screen is still running, so data/db-config.json did not take. Check step 2."

curl -sSL "https://${DOMAIN_HOST}/" | grep -qi 'uptime kuma' \
	|| die "the page answered 200 but does not mention Uptime Kuma. Check: docker compose logs --tail 40 uptime-kuma"

# --- 6. Create the administrator account -------------------------------------

cat <<-SETUP

	Open https://${DOMAIN_HOST} now and create the administrator account. Until
	you do, whoever loads that page first can create it instead. Then reload the
	page in a private window: you should get a sign-in form and no
	create-account fields.

SETUP
printf 'Press Return once you are signed in and the private window shows a sign-in form. '
read -r _

# --- 7. The first backup, before day one ends --------------------------------
#
# Stopped, then copied. A SQLite file captured mid-write is not a backup.

docker compose stop
sudo tar -C "$APP_DIR" -czf "$APP_DIR/backups/uptime-kuma-$(date +%Y%m%d-%H%M%S).tar.gz" data
docker compose start
ls -lh "$APP_DIR/backups/"

cat <<-DONE

	Uptime Kuma is running at https://${DOMAIN_HOST}/

	  1. Add a monitor, then add a second one pointed at a hostname that does
	     not exist, and wait for it to go red. If no alert reaches your phone,
	     your notification channel is decoration. Test it today.
	  2. Point one free external check at ${DOMAIN_HOST} itself. This server
	     cannot tell you it is down.
	  3. Heartbeat history is kept forever by default. On a small disk that is
	     what fills it; the retention setting in the interface is the lever.
	  4. 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

What 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 UptimeRobot.

  • A monitor you host cannot tell you that the machine hosting it is down. Run this somewhere other than the servers it watches, and keep one free external check pointed at the monitor itself. This is the one place where paying nobody is not the right answer.
  • You own the alerting path. Uptime Kuma will happily send to a notification service you set up once and never test again; send yourself a deliberate test alert the day you install it, and again whenever you change anything.
  • You own the backups. Everything is one SQLite database under /srv/uptime-kuma/data. Copy that directory off the box, and copy it while the container is stopped, because SQLite mid-write is not a backup.
  • It keeps history forever unless you tell it not to. On a small disk, a year of 20-second checks across a lot of monitors is the thing that fills it up.
  • No outside vantage point, no phone-network SMS gateway, no support contract. Those three are what the paid tiers are actually selling.

Where this came from

“Filesystem support for POSIX file locks is required to avoid SQLite database corruption”

  • Upstream's own compose file runs a single service, publishes port 3001, and keeps all state in a volume mounted at /app/data. source
  • Uptime Kuma's UI talks over WebSockets, and Caddy's reverse_proxy handles the upgrade without any extra configuration. source
  • The data directory must sit on a filesystem that supports POSIX file locks, which rules out mounting it from network storage. source
  • The 2.x image runs as the node user, exposes 3001, and carries a health check with a three-minute start period. source
  • The database is selected by a db-config.json file in the data directory, and writing one with a type of sqlite before the first boot skips the database setup screen. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host UptimeRobot?

    Not UptimeRobot 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 Uptime Kuma. Uptime monitoring and status pages from one container, with no monitor quota and no per-check billing. 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 8 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 UptimeRobot?

    Uptime Kuma. Uptime monitoring and status pages from one container, with no monitor quota and no per-check billing. Matches the paid feature set that people actually use — sub-minute checks, unlimited monitors, status pages, and notifications to the twenty-odd services you already have — from one container with no database to operate. It cannot replace the outside-your-network vantage point, which is the honest limit and the reason the page recommends keeping a free external monitor pointed at it. Uptime Kuma is MIT-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to UptimeRobot?

    512 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. Uptime Kuma itself is free and MIT-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: UptimeRobot Solo, $10/mo — $120 a year.

  • 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 Uptime Kuma install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run Uptime Kuma 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 Uptime Kuma 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: The checks run only while this computer is awake, and nothing here can tell you when the computer itself is the thing that is down. 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-05. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.