Can I self-host Cronitor?

YES · ONE EVENING— setup effort 2 of 4

YES — it's called Healthchecks. It takes one prompt, a 1024 MB VPS, and about 90 minutes. That is $2 a month you stop paying Cronitor — $24 a year on the Business plan, a metered rate, not a whole bill.

Why people pay for Cronitor

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.

Cronitor watches the jobs nobody watches: the nightly backup, the invoice run, the sync that has quietly not run since March. It notices absence, which is harder than noticing failure, and it does it from infrastructure that is not yours, so the alert still leaves the building when your building is the problem.

Cronitor plans and list prices
PlanList priceWhat it buys
HackerfreeFree. 5 monitors, one basic status page, email and Slack alerts only. No SMS, no premium integrations.
Businessthe plan this page prices against$2/mo metered$2/month per monitor, plus $5/month per additional user. 12-month data retention, SMS alerts and premium integrations included. Volume discounts apply. 14-day free trial.
Enterprisequote onlyFrom $6,000/year. Custom integrations, a dedicated engineer, invoice billing.

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

Replaced by Healthchecks

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

A dead-man's switch for every cron job you own, with no monitor quota and no per-check billing.

Same dead-man's-switch model, same one-line curl at the end of a cron job, and no monitor quota at all. It is the honest match because it is the same idea rather than a near miss: a check that has to be pinged, and an alert when the ping does not arrive. What it cannot do is send that alert from somewhere other than your own server, which is the limit the page leads with.

The swap

You're paying

Cronitor

$2/mo · $24/yr

is replaced by

You'd run

Healthchecks

ONE EVENING · ~90 min to running · 1024 MB RAM

Cronitor Business · a metered rate, not a whole bill · vendor list price · checked 2026-08-05 · source

Before you start

RAM floor
1024 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
~90 min1–3 hours, through the first backup

The prompt

One prompt, assuming Prompt Zero is done. It installs Healthchecks — read it before you paste it, which is the whole reason it is on the page instead of behind a download.

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

271 lines · 10,681 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 Healthchecks v4.3 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` or `<ADMIN_EMAIL>` is still literal, ask for both once and stop until the user
answers. The A record must already point here. In the same message ask three more things and
then stop asking: an SMTP relay hostname they already have, its port, and their username on it.
Do not ask for the relay credential; a STOP in step 3 has the user type that in themselves.
Healthchecks needs 1024 MB of RAM available and 5 GB free on /srv, on amd64 or 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 1024 MB or disk under 5 GB, print both and stop. If `dig +short` prints
nothing, stop: Caddy cannot certify a hostname that does not resolve.

## 2. Layout

The image creates /data and hands it to a system account called `hc`. Ask the image which uid
that is, rather than assuming: a system uid is assigned at build time.

```bash
IMG=healthchecks/healthchecks:v4.3@sha256:cd7bcd94350818b3944f82eb5995f48bdeab8c8627977578a569ffa73f56f56f
docker pull "$IMG"
HCUID=$(docker run --rm "$IMG" id -u hc)
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/healthchecks /srv/healthchecks/backups
sudo install -d -m 750 -o "$HCUID" -g "$HCUID" /srv/healthchecks/data
ls -la /srv/healthchecks
```

Assert: `ls -la` shows `backups` owned by the login user and `data` owned by a numeric uid.
Nothing is written outside /srv/healthchecks.

## 3. Secrets

One secret is generated here: the Django `SECRET_KEY`. Do not print it, repeat it in your
summary, or log it. Replace `smtp.example.net`, `587` and `EMAIL_HOST_USER` with the step 1
values.

```bash
umask 077
cat > /srv/healthchecks/.env <<EOF
SECRET_KEY=$(openssl rand -base64 48)
SITE_ROOT=https://<DOMAIN>
SITE_NAME=Checks
ALLOWED_HOSTS=<DOMAIN>
DB=sqlite
DB_NAME=/data/hc.sqlite
REGISTRATION_OPEN=True
DEFAULT_FROM_EMAIL=<ADMIN_EMAIL>
EMAIL_HOST=smtp.example.net
EMAIL_PORT=587
EMAIL_HOST_USER=<ADMIN_EMAIL>
EMAIL_USE_TLS=True
EOF
chmod 600 /srv/healthchecks/.env
ls -l /srv/healthchecks/.env
```

Assert: mode `-rw-------`. `SITE_ROOT` is what every absolute link is built from, ping URLs
included, so it carries https even though the container speaks plain HTTP to Caddy.

STOP: tell the user to open their own terminal and run the block below on the server, so the
relay credential never enters this session. The third line waits with no prompt and echoes
nothing. Wait until they report what the last line printed.

```bash
umask 077
printf 'EMAIL_HOST_PASSWORD=' >> /srv/healthchecks/.env
read -rs && printf '%s\n' "$REPLY" >> /srv/healthchecks/.env
unset REPLY
chmod 600 /srv/healthchecks/.env
sudo awk -F= '/^EMAIL_HOST_PASSWORD/ {print "recorded, length " length($2)}' /srv/healthchecks/.env
```

Assert: a length greater than 0. Nothing printed means the line is missing.

## 4. compose.yml

```bash
cat > /srv/healthchecks/compose.yml <<'EOF'
# Healthchecks · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   image and proxy notes . https://github.com/healthchecks/healthchecks/blob/master/docker/README.md
#   configuration ......... https://healthchecks.io/docs/self_hosted_configuration/
#
# One container. DB=sqlite means no database process to operate: the instance is
# one file at /data/hc.sqlite, and uWSGI runs migrations on boot and keeps
# sendalerts alive, so there is no cron job. Tag and digest are the v4.3 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:
  healthchecks:
    image: healthchecks/healthchecks:v4.3@sha256:cd7bcd94350818b3944f82eb5995f48bdeab8c8627977578a569ffa73f56f56f
    container_name: healthchecks
    restart: unless-stopped
    env_file: /srv/healthchecks/.env
    volumes:
      # Owned by the hc uid, hence step 2. SQLite needs real POSIX file locks.
      - /srv/healthchecks/data:/data
    ports:
      # Loopback only. The Caddy that Prompt Zero installed on the host is the
      # only thing that can reach this port, and 8088 never enters the firewall.
      - "127.0.0.1:8088:8000"
EOF
cd /srv/healthchecks && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. The container serves on 8000 inside itself; 8088 is bound to
127.0.0.1, so the only route in is Caddy.

## 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-healthchecks
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Healthchecks · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://caddyserver.com/docs/automatic-https
# and https://github.com/healthchecks/healthchecks/blob/master/docker/README.md
#
# Append this to /etc/caddy/Caddyfile, with <DOMAIN> replaced by the hostname
# pointed at this box. Caddy runs under systemd. No Caddy container here.

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

	# 8088 is the loopback port compose publishes; it is never in the firewall.
	# Caddy replaces any client-supplied X-Forwarded-Proto with the real scheme,
	# which is what uWSGI in this image reads to decide a request is secure.
	reverse_proxy 127.0.0.1:8088
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: both exit 0. If validate fails, restore /etc/caddy/Caddyfile.before-healthchecks,
reload, and report what it objected to. Caddy gets the certificate on the first request.

## 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. 8088 stays closed, bound to 127.0.0.1, and nothing opens 25, 465 or 587: this box
sends through the user's relay and accepts no mail. Assert: `ufw status verbose` prints
`Status: active`, shows 80, 443/tcp and 443/udp, and no 8088.

## 7. Start and verify

uWSGI runs the migrations as it boots, so the first start writes hc.sqlite and is the slow one.

```bash
cd /srv/healthchecks
docker compose up -d
sleep 20
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/v3/status/
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/accounts/signup/
```

Assert: both print `200`, and print what you received. The first is the image's own
health-check endpoint and answers 200 only when the database connection is alive, which is
worth more than a green `docker ps`. If either misses, stop, run
`docker compose logs --tail 40 healthchecks`, and name the likely earlier step. The first
screen at https://<DOMAIN> is a sign-in form offering to email a sign-in link.

STOP: tell the user to open https://<DOMAIN>/accounts/signup/, sign up with <ADMIN_EMAIL>, and
click the link they are emailed. Wait until they confirm they are signed in. If no mail
arrives, send them to step 10 before anything is changed.

Now close registration, so this is not a public signup form:

```bash
sed -i 's/^REGISTRATION_OPEN=True$/REGISTRATION_OPEN=False/' /srv/healthchecks/.env
cd /srv/healthchecks && docker compose up -d --force-recreate
sleep 20
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/v3/status/
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/accounts/signup/
```

Assert: the first prints `200`, the second anything other than `200`. Print both. The second is
the security assert: it stops a stranger who finds the hostname creating an account here.

## 8. First backup and restore

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

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

Assert: the archive exists and is non-empty. Print its size. `data` plus `.env` is the whole
install, relay credential included. A backup on the same disk is not one, so run this from the
user's machine:

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

To restore: `docker compose down`, `sudo rm -rf /srv/healthchecks/data`,
`sudo tar -C /srv/healthchecks -xzf` the archive, then `docker compose up -d`. Checks, history
and ping URLs all live in `data/hc.sqlite`, so URLs already in crontabs elsewhere survive.
Those four commands are the whole disaster plan.

## 9. Updating later

New versions are at https://github.com/healthchecks/healthchecks/releases. Back up first, then
edit the image line in /srv/healthchecks/compose.yml to the new tag and digest. uWSGI migrates
on the next boot, so read the log until it settles before calling this done.

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

## 10. What will probably go wrong

The signup email will not arrive, and the install will look broken when it is not. Hetzner
blocks outbound 25, 465 and 587 on new cloud accounts until you open a support ticket, and
DigitalOcean restricts them too. I sat watching an empty inbox for ten minutes with a container
that had been answering 200 the whole time. Run `docker compose logs --tail 40 healthchecks`
and look for a timeout to the relay host. That is the provider, and the fix is a ticket with a
lead time in days.

## 11. Out of scope

- Do not switch the database to PostgreSQL. SQLite is why this is one file to copy.
- Do not set `SMTPD_PORT` or open an inbound mail listener. This install only sends.
- Do not configure Slack, Telegram or any other channel. Each is an account elsewhere.
- Do not add a cron job for alerts. uWSGI keeps `sendalerts` running already.
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 Healthchecks v4.3 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 and `<ADMIN_EMAIL>` with the address your account will use.

Before you start, have three things to hand: the hostname of an SMTP relay you already have,
its port, and your username on it. Healthchecks tells you when something did not happen, and it
does that by email. Without a relay you can install this and still not have a monitor.

## 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 `1024` 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 creates `/data` inside itself and hands it to a system account called `hc`. That
account's uid is decided when the image is built, so ask the image rather than guessing.

```bash
IMG=healthchecks/healthchecks:v4.3@sha256:cd7bcd94350818b3944f82eb5995f48bdeab8c8627977578a569ffa73f56f56f
docker pull "$IMG"
HCUID=$(docker run --rm "$IMG" id -u hc)
echo "hc is uid $HCUID"
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/healthchecks /srv/healthchecks/backups
sudo install -d -m 750 -o "$HCUID" -g "$HCUID" /srv/healthchecks/data
ls -la /srv/healthchecks
```

You should see: a line like `hc is uid 999`, then `backups` owned by your own username and
`data` owned by that number.

If you do not: `docker: permission denied` means your session predates the docker group change
from Prompt Zero, so log out and back in. `data` owned by you means the last install line did
not run, and the container will fail to write its database with an error that mentions nothing
about ownership.

## 3. Secrets

One secret is generated here: the Django `SECRET_KEY`. Before you paste, edit three lines in
the block: `EMAIL_HOST` to your relay's hostname, `EMAIL_PORT` to its port, and
`EMAIL_HOST_USER` to your username on it if that is not your email address.

```bash
umask 077
cat > /srv/healthchecks/.env <<EOF
SECRET_KEY=$(openssl rand -base64 48)
SITE_ROOT=https://<DOMAIN>
SITE_NAME=Checks
ALLOWED_HOSTS=<DOMAIN>
DB=sqlite
DB_NAME=/data/hc.sqlite
REGISTRATION_OPEN=True
DEFAULT_FROM_EMAIL=<ADMIN_EMAIL>
EMAIL_HOST=smtp.example.net
EMAIL_PORT=587
EMAIL_HOST_USER=<ADMIN_EMAIL>
EMAIL_USE_TLS=True
EOF
chmod 600 /srv/healthchecks/.env
ls -l /srv/healthchecks/.env
```

You should see: mode `-rw-------`, your own username twice, and the path.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines one at a time in different shells. Run `chmod 600 /srv/healthchecks/.env`
and carry on.

Now add the relay credential. These five lines never echo it and never put it in your shell
history:

```bash
umask 077
printf 'EMAIL_HOST_PASSWORD=' >> /srv/healthchecks/.env
read -rs && printf '%s\n' "$REPLY" >> /srv/healthchecks/.env
unset REPLY
chmod 600 /srv/healthchecks/.env
```

You should see: nothing at all after the third line. The cursor sits there waiting. Type or
paste the credential, press Return, and you are back at a prompt. Then check the shape of it
without reading it back:

```bash
sudo awk -F= '/^EMAIL_HOST_PASSWORD/ {print "recorded, length " length($2)}' /srv/healthchecks/.env
```

You should see: `recorded, length` and a number greater than zero.

If you do not: no output means the line is missing, so run the five-line block again. A length
of `0` means you pressed Return before typing anything: edit the file with
`sudo nano /srv/healthchecks/.env` and fix that one line.

Do not paste the contents of that file, the relay credential, or any command output containing
it into this chat window. Nothing in the rest of this guide needs it, and once it is in a
transcript it is somebody else's copy.

## 4. compose.yml

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

```bash
cat > /srv/healthchecks/compose.yml <<'EOF'
# Healthchecks · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   image and proxy notes . https://github.com/healthchecks/healthchecks/blob/master/docker/README.md
#   configuration ......... https://healthchecks.io/docs/self_hosted_configuration/
#
# One container. DB=sqlite means no database process to operate: the instance is
# one file at /data/hc.sqlite, and uWSGI runs migrations on boot and keeps
# sendalerts alive, so there is no cron job. Tag and digest are the v4.3 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:
  healthchecks:
    image: healthchecks/healthchecks:v4.3@sha256:cd7bcd94350818b3944f82eb5995f48bdeab8c8627977578a569ffa73f56f56f
    container_name: healthchecks
    restart: unless-stopped
    env_file: /srv/healthchecks/.env
    volumes:
      # Owned by the hc uid, hence step 2. SQLite needs real POSIX file locks.
      - /srv/healthchecks/data:/data
    ports:
      # Loopback only. The Caddy that Prompt Zero installed on the host is the
      # only thing that can reach this port, and 8088 never enters the firewall.
      - "127.0.0.1:8088:8000"
EOF
cd /srv/healthchecks && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/healthchecks/.env not found` means step 3 did not write the file,
so go back. `services must be a mapping` means the indentation was lost between the page and
your terminal: run `rm /srv/healthchecks/compose.yml` and paste the block again in one go.

## 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-healthchecks
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Healthchecks · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://caddyserver.com/docs/automatic-https
# and https://github.com/healthchecks/healthchecks/blob/master/docker/README.md
#
# Append this to /etc/caddy/Caddyfile, with <DOMAIN> replaced by the hostname
# pointed at this box. Caddy runs under systemd. No Caddy container here.

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

	# 8088 is the loopback port compose publishes; it is never in the firewall.
	# Caddy replaces any client-supplied X-Forwarded-Proto with the real scheme,
	# which is what uWSGI in this image reads to decide a request is secure.
	reverse_proxy 127.0.0.1:8088
}
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-healthchecks /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 `8088`, `25`, `465` or `587`.

If you do not: a rule for `8088` from an earlier attempt should go, with
`sudo ufw delete allow 8088`. 8088 is bound to 127.0.0.1 by the compose file, so nothing
outside the machine can reach it. The mail ports stay closed because this box sends outbound
through your relay and never accepts mail.

## 7. Start and verify

uWSGI runs the database migrations as it boots, so the first start writes `hc.sqlite` and takes
longer than every later one.

```bash
cd /srv/healthchecks
docker compose up -d
sleep 20
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/v3/status/
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/accounts/signup/
```

You should see: `200` twice. The first URL is the endpoint the image's own health check uses,
and it answers 200 only when the database connection is alive.

If you do not: `000` or `502` means the certificate is not there yet, so run
`sudo journalctl -u caddy -n 30`. `500` on the status endpoint usually means the `data`
directory is not writable by the container, which is step 2 done wrong: check with
`docker compose logs --tail 40 healthchecks`.

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

Now open https://<DOMAIN>/accounts/signup/ and sign up with your address. Healthchecks emails
you a sign-in link, and that link is how you get in the first time. If it does not arrive,
read step 10 before you change anything: this is the single most likely place for this install
to stall, and it is usually not Healthchecks.

Once you are signed in, close registration so your monitor is not a public signup form:

```bash
sed -i 's/^REGISTRATION_OPEN=True$/REGISTRATION_OPEN=False/' /srv/healthchecks/.env
cd /srv/healthchecks && docker compose up -d --force-recreate
sleep 20
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/v3/status/
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/accounts/signup/
```

You should see: `200`, then anything that is not `200`.

If you do not: a second `200` means the file was not edited, so check with
`grep REGISTRATION /srv/healthchecks/.env`. Until that reads `False`, anyone who finds your
hostname can create an account on your monitor.

## 8. First backup and restore

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

```bash
cd /srv/healthchecks
docker compose stop
sudo tar -C /srv/healthchecks -czf /srv/healthchecks/backups/healthchecks-$(date +%F).tar.gz data .env
docker compose start
ls -lh /srv/healthchecks/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/healthchecks/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/healthchecks
scp vps:/srv/healthchecks/backups/*.tar.gz ~/backups/healthchecks/
```

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

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/healthchecks
docker compose down
sudo rm -rf /srv/healthchecks/data
sudo tar -C /srv/healthchecks -xzf /srv/healthchecks/backups/healthchecks-$(date +%F).tar.gz
docker compose up -d
```

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

If you do not: a sign-in page that has turned back into a signup form means the archive was
taken before you closed registration, which is harmless here but tells you the archive is
older than you thought. Take another one. Those four commands are the whole disaster plan, and
you have now run them once.

## 9. Updating later

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

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

You should see: `Recreated`, then migration lines, then uWSGI workers starting and no repeating
restart.

If you do not: put the old tag and digest back and run the same three commands. uWSGI runs the
migrations on the next boot, so read that log before you call the update done.

## 10. What will probably go wrong

The signup email will not arrive, and the install will look broken when it is not. Hetzner
blocks outbound 25, 465 and 587 on new cloud accounts until you open a support ticket, and
DigitalOcean restricts them too. I sat watching an empty inbox for ten minutes with a container
that had been answering 200 the whole time. Run `docker compose logs --tail 40 healthchecks`
and look for a connection timeout to your relay host. That is the provider, not this install,
and the fix is a support ticket with a lead time measured in days.

## 11. Out of scope

- Do not switch the database to PostgreSQL. SQLite is why this is one file to copy.
- Do not set `SMTPD_PORT` or open an inbound mail listener. This install only sends.
- Do not configure Slack, Telegram or any other channel. Each is an account elsewhere.
- Do not add a cron job for the alert sender. uWSGI keeps `sendalerts` running already.

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

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

# Healthchecks · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   image and proxy notes . https://github.com/healthchecks/healthchecks/blob/master/docker/README.md
#   configuration ......... https://healthchecks.io/docs/self_hosted_configuration/
#
# One container. DB=sqlite means no database process to operate: the instance is
# one file at /data/hc.sqlite, and uWSGI runs migrations on boot and keeps
# sendalerts alive, so there is no cron job. Tag and digest are the v4.3 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:
  healthchecks:
    image: healthchecks/healthchecks:v4.3@sha256:cd7bcd94350818b3944f82eb5995f48bdeab8c8627977578a569ffa73f56f56f
    container_name: healthchecks
    restart: unless-stopped
    env_file: /srv/healthchecks/.env
    volumes:
      # Owned by the hc uid, hence step 2. SQLite needs real POSIX file locks.
      - /srv/healthchecks/data:/data
    ports:
      # Loopback only. The Caddy that Prompt Zero installed on the host is the
      # only thing that can reach this port, and 8088 never enters the firewall.
      - "127.0.0.1:8088:8000"
Caddyfilethe hostname and TLS24 lines

authored from upstream docs, never pasted · 852 bytes

# Healthchecks · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://caddyserver.com/docs/automatic-https
# and https://github.com/healthchecks/healthchecks/blob/master/docker/README.md
#
# Append this to /etc/caddy/Caddyfile, with <DOMAIN> replaced by the hostname
# pointed at this box. Caddy runs under systemd. No Caddy container here.

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

	# 8088 is the loopback port compose publishes; it is never in the firewall.
	# Caddy replaces any client-supplied X-Forwarded-Proto with the real scheme,
	# which is what uWSGI in this image reads to decide a request is secure.
	reverse_proxy 127.0.0.1:8088
}
install.shthe same install, no agent179 lines

authored from upstream docs, never pasted · 7,519 bytes

#!/usr/bin/env bash
# Healthchecks · 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=checks.example.com ADMIN_EMAIL=you@example.com \
#     RELAY_HOST=smtp.example.net RELAY_PORT=587 RELAY_USER=you@example.com ./install.sh
#
# It will prompt once, silently, for the relay credential. That value is never
# echoed and never reaches your shell history.
#
# Authored by caniselfhostit from the upstream documentation:
#   https://github.com/healthchecks/healthchecks/blob/master/docker/README.md
#   https://healthchecks.io/docs/self_hosted_configuration/
#   https://caddyserver.com/docs/automatic-https
#
# One secret is generated here, on this machine: the Django SECRET_KEY. It is
# written to /srv/healthchecks/.env with mode 600 and never printed.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/healthchecks}"
IMAGE="healthchecks/healthchecks:v4.3@sha256:cd7bcd94350818b3944f82eb5995f48bdeab8c8627977578a569ffa73f56f56f"
DOMAIN_HOST="${DOMAIN_HOST:-}"
ADMIN_EMAIL="${ADMIN_EMAIL:-}"
RELAY_HOST="${RELAY_HOST:-}"
RELAY_PORT="${RELAY_PORT:-587}"
RELAY_USER="${RELAY_USER:-$ADMIN_EMAIL}"

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. checks.example.com"
[ -n "$ADMIN_EMAIL" ] || die "set ADMIN_EMAIL to the address your first account will use"
[ -n "$RELAY_HOST" ]  || die "set RELAY_HOST to an SMTP relay you already have. Alert mail is the whole product."
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."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 1024 ] || die "only ${avail_mb} MB of RAM available; this install wants 1024 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# The image creates /data and hands it to a system account called hc. Ask the
# image which uid that is rather than assuming one.

docker pull "$IMAGE"
HCUID="$(docker run --rm "$IMAGE" id -u hc)"
[ -n "$HCUID" ] || die "could not read the hc uid out of the image"
echo "==> the container writes as uid ${HCUID}"

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 750 -o "$HCUID" -g "$HCUID" "$APP_DIR/data"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. One generated secret, plus the relay credential you already own ------
#
# SECRET_KEY has never existed anywhere else. The relay credential is typed in
# below, silently, and appended without ever being echoed.

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		SECRET_KEY=$(openssl rand -base64 48)
		SITE_ROOT=https://${DOMAIN_HOST}
		SITE_NAME=Checks
		ALLOWED_HOSTS=${DOMAIN_HOST}
		DB=sqlite
		DB_NAME=/data/hc.sqlite
		REGISTRATION_OPEN=True
		DEFAULT_FROM_EMAIL=${ADMIN_EMAIL}
		EMAIL_HOST=${RELAY_HOST}
		EMAIL_PORT=${RELAY_PORT}
		EMAIL_HOST_USER=${RELAY_USER}
		EMAIL_USE_TLS=True
	ENVFILE
	printf 'EMAIL_HOST_PASSWORD=' >> "$APP_DIR/.env"
	printf 'Relay credential for %s (input is hidden): ' "$RELAY_USER" > /dev/tty
	read -rs relay_value < /dev/tty
	printf '\n' > /dev/tty
	printf '%s\n' "$relay_value" >> "$APP_DIR/.env"
	unset relay_value
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

sudo awk -F= '/^EMAIL_HOST_PASSWORD/ {print "relay credential recorded, length " length($2)}' "$APP_DIR/.env"

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. 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-healthchecks"
	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

# --- 5. Ports: two open, and 8088 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; 8088 stays closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it and prove it works ------------------------------------------

docker compose up -d
sleep 20

echo "==> waiting for https://${DOMAIN_HOST}/api/v3/status/ (Caddy is getting a certificate)"
for _ in $(seq 1 30); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/v3/status/" || true)"
	[ "$code" = "200" ] && break
	sleep 5
done
[ "${code:-}" = "200" ] || die "the status endpoint answered ${code:-nothing}. Check: docker compose logs --tail 40 healthchecks"

# --- 7. Create your account, then close registration -------------------------

cat <<-SIGNUP

	Open https://${DOMAIN_HOST}/accounts/signup/ now, sign up as ${ADMIN_EMAIL},
	and click the link Healthchecks emails you. That link is how you get in the
	first time, so if it does not arrive, read step 10 of the prompt before you
	change anything: outbound mail is blocked by default on most new VPS
	accounts, and that is not a bug in this install.

SIGNUP
printf 'Press Return once you are signed in. '
read -r _

sed -i 's/^REGISTRATION_OPEN=True$/REGISTRATION_OPEN=False/' "$APP_DIR/.env"
docker compose up -d --force-recreate
sleep 20
signup_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/accounts/signup/" || true)"
echo "==> the sign-up page now answers ${signup_code}"
[ "$signup_code" != "200" ] || die "sign-up is still open. Check REGISTRATION_OPEN in $APP_DIR/.env and recreate."

# --- 8. 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/healthchecks-$(date +%Y%m%d-%H%M%S).tar.gz" data .env
docker compose start
ls -lh "$APP_DIR/backups/"

cat <<-DONE

	Healthchecks is running at https://${DOMAIN_HOST}/

	  1. Make your first check, then run the ping URL by hand once. A check that
	     has never been pinged has never proved anything.
	  2. Break one on purpose: set a period of one minute and do not ping it.
	     If no mail arrives, your relay is the problem, not Healthchecks.
	  3. This monitor runs on this server. It cannot tell you that this server
	     is down. Keep one free external check pointed at ${DOMAIN_HOST}.
	  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 Cronitor.

  • A monitor you host cannot tell you that the machine hosting it is down, and one that emails through a relay cannot tell you when the relay is down either. Keep one free external check pointed at this instance. This is the one place where paying nobody is not the right answer.
  • You need an SMTP relay you already have before you start. Healthchecks signs you in by emailing a link, so mail is not a nicety here, it is the front door. New Hetzner and DigitalOcean accounts block outbound mail ports until you ask, and that request takes days.
  • You own the backups. The whole instance is one SQLite file at /srv/healthchecks/data/hc.sqlite, and it has to be copied with the container stopped. The ping URLs live in there too, so a restore keeps the crontab lines you already pasted on other machines working.
  • Nothing is monitored until you paste a ping URL into something. Healthchecks watches for the absence of a signal, which means an empty dashboard is not a healthy system, it is an unwired one. Break a check on purpose the day you install it.
  • No SMS gateway, no phone-tree escalation, no on-call rota. Those three are what the paid tiers are actually selling, and none of them arrive with this container.

Where this came from

“uWSGI is configured to perform database migrations on startup, and to run sendalerts, sendreports, and smtpd in the background.”

  • The published image runs uWSGI, which performs the database migrations on startup and keeps the alert sender and the report sender running in the background, so a self-hosted install needs no cron job of its own. source
  • DB defaults to sqlite, DB_NAME sets the database file path, SITE_ROOT is the base URL every absolute link is built from, and REGISTRATION_OPEN controls whether visitors can create accounts. source
  • The image's own health check polls /api/v3/status/, which answers only when the database connection is alive. source
  • Hetzner blocks outbound SMTP on ports 25, 465 and 587 for new cloud accounts until support unblocks them, which is why the alert path is the first thing to test. source

Questions people actually ask

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

  • Can I self-host Cronitor?

    Not Cronitor 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 Healthchecks. A dead-man's switch for every cron job you own, with no monitor quota and no per-check billing. The install is one evening: 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 90 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 Cronitor?

    Healthchecks. A dead-man's switch for every cron job you own, with no monitor quota and no per-check billing. Same dead-man's-switch model, same one-line curl at the end of a cron job, and no monitor quota at all. It is the honest match because it is the same idea rather than a near miss: a check that has to be pinged, and an alert when the ping does not arrive. What it cannot do is send that alert from somewhere other than your own server, which is the limit the page leads with. Healthchecks is BSD-3-Clause-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Cronitor?

    1024 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. Healthchecks itself is free and BSD-3-Clause-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Cronitor Business, $2/mo — $24 a year, a metered rate, not a whole bill.

  • How hard is it really?

    ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Healthchecks 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.