Can I self-host UptimeRobot?
YES · ONE COMMAND— setup effort 1 of 4YES — 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.
| Plan | List price | What it buys |
|---|---|---|
| Free | free | 50 monitors, 5-minute checks. |
| Solothe plan this page prices against | $10/mo | 10 monitors, 60-second checks. $9/mo if paid annually. |
| Team | $34/mo | 100 monitors, 30-second checks. $29/mo if paid annually. |
| Scale | $64/mo | 200 to 500 monitors, 15-second checks. $54/mo if paid annually. |
| Enterprise | quote only | Quote 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'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
- Time budget
- ~8 minunder 10 minutes, through the first backup
The prompt
One prompt, assuming Prompt Zero is done. It installs Uptime Kuma — read it before you paste it, which is the whole reason it is on the page instead of behind a download.
254 lines · 11,075 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install 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.agent-readable mirror: /self-host/uptimerobot.md
The files, if you'd rather do it yourself
The same install with no agent involved: three files, in the order you'd use them. The prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs.
compose.ymlthe services, pinned29 lines
# 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
# 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
#!/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.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel 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.
Content last checked 2026-08-05. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.