# Can I self-host UptimeRobot?

**YES** — it's called Uptime Kuma. ONE COMMAND setup · ~8 minutes to running · 512 MB RAM minimum · $10/mo you stop paying ($120/yr on the Solo plan).

Uptime Kuma authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/uptimerobot/

## Install prompt (Claude Code)

````text
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

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

## Chat fallback

````text
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.

You are installing 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.
````

## docker-compose.yml

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

## Caddyfile

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

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

The page this mirrors: https://caniselfhostit.com/self-host/uptimerobot/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
