Can I self-host Cronitor?
YES · ONE EVENING— setup effort 2 of 4YES — 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.
| Plan | List price | What it buys |
|---|---|---|
| Hacker | free | Free. 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. |
| Enterprise | quote only | From $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'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 on the cloud path, and the local path needs none
- Time budget
- ~90 min1–3 hours, through the first backup
The prompt
Two paths to the same Healthchecks: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
275 lines · 11,005 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 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 https://<DOMAIN>/accounts/login/ | grep -c 'id="signup-modal"'
```
Assert: the first prints `200`, the second a number greater than 0, 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 headed `Log In to Checks`, with an
`Email Me a Link` button and a `Sign Up` link that opens the sign-up form. `/accounts/signup/`
takes POST only, so there is no page to open there.
STOP: tell the user to open https://<DOMAIN>, click `Sign Up`, 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 https://<DOMAIN>/accounts/login/ | grep -c 'id="signup-modal"' || true
```
Assert: the first prints `200`, the second `0`. Print both. That `0` is the security assert,
`signup-modal` being the id of the sign-up form itself, so it counts the form gone from the
log-in page; `grep -c` exits 1 counting nothing, hence `|| true`. 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 https://<DOMAIN>/accounts/login/ | grep -c 'id="signup-modal"'
```
You should see: `200`, then a number greater than 0. The first URL is the endpoint the image's
own health check uses, and it answers 200 only when the database connection is alive. The
second counts the sign-up form on the log-in page.
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>, click `Sign Up`, and sign up with your address. Healthchecks emails
you a sign-in link, and that link is how you get in the first time. There is no page at
/accounts/signup/ to open directly; it takes POST only. 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 https://<DOMAIN>/accounts/login/ | grep -c 'id="signup-modal"' || true
```
You should see: `200`, then `0`.
If you do not: anything but `0` 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.335 lines · 14,959 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- 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 own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.
Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.
Install Healthchecks v4.3 on this computer, reachable at http://localhost:8088, with everything
it owns under ~/selfhost/healthchecks/.
## 1. Preflight
Say this to the user before anything is installed, without softening it: nothing outside this
computer can reach a localhost ping URL, so this instance can only watch cron jobs that run on
this same machine, and the jobs on their servers will have to report somewhere else.
Healthchecks signs people in by emailing a link, so an SMTP relay is a precondition. Ask once,
then stop asking: the address the account will use, a relay hostname they have, its port, and
their username on it. Not the credential; a STOP in step 4 has them type that. No relay, no
install.
Now measure the machine:
```bash
uname -s
case "$(uname -s)" in
Darwin) sysctl -n hw.memsize | awk '{print $1/1048576 " MB of RAM installed"}' ;;
Linux) free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory" | awk '{print $1/1048576 " MB of RAM installed"}' ;;
esac
df -h ~
```
`uname -s` prints `Darwin`, `Linux`, or `MINGW`/`MSYS` in Git Bash on Windows; every branch
below turns on that answer, and step 3 adds a Windows rule. The RAM lines print MB: on Linux
what is free now, on macOS and Windows what is installed.
Healthchecks needs 1024 MB of RAM and 5 GB free on the home disk, on amd64 or arm64. Stop if
Linux shows under 1024 MB available, if macOS or Windows shows under 2048 MB installed, or if
`Avail` is under 5 GB. Do not install and hope.
## 2. Docker
Check before installing anything:
```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```
If that printed `docker OK` and a compose version, skip to step 3.
Otherwise, install Docker for the OS step 1 detected:
- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
Homebrew, STOP: tell the user to download Docker Desktop from
https://www.docker.com/products/docker-desktop/ and install it, and wait until they confirm.
Either way, then STOP: tell the user to open Docker Desktop once, accept its terms, and wait
for the whale icon to say it is running. Do not continue until they confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
install fails, STOP: tell the user to download Docker Desktop from the URL above and install
it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may ask for a
reboot; if it does, STOP and tell the user to reboot and come back, this prompt resumes at
this step. Then STOP: have the user open Docker Desktop, accept its terms, and confirm it
says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt repository,
saving the signing key to a file first, never piping it into a shell:
```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker "$USER"
fi
```
Adding the user to the docker group is root-equivalent on this machine; say that to the user
in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose plugin
with their distribution's package manager, and to run this prompt again once `docker info`
works.
Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not continue
without both.
## 3. Layout
STOP on Windows, before anything is created. SQLite write locks are not honoured across a
Windows drive Docker Desktop shares in, and uWSGI writes this file from several processes, so a
`/mnt/c` path corrupts it silently. Windows runs this inside WSL 2 instead: tell the user to run
`wsl --install -d Ubuntu` in PowerShell, choose the UNIX username and password its first launch
asks for, turn Ubuntu on under Docker Desktop, Settings, Resources, WSL integration, then open
Claude Code in that Ubuntu window and paste this prompt there. Nothing is created in Git Bash.
```bash
mkdir -p ~/selfhost/healthchecks/data ~/selfhost/healthchecks/backups
cd ~/selfhost/healthchecks
chmod 750 . data backups
ls -la
```
Assert: `ls -la` lists `data` and `backups` at `drwxr-x---`, and nothing is written outside that
folder; `backups` carries that mode because step 8's archive holds `.env`. Keep it on this
computer's own disk: no sync-service folder, no network drive.
On Linux the bind mount must belong to the uid the container writes as. The image hands /data
to a system account `hc`, so ask it:
```bash
cd ~/selfhost/healthchecks
if [ "$(uname -s)" = "Linux" ]; then
HCUID=$(docker run --rm healthchecks/healthchecks:v4.3@sha256:cd7bcd94350818b3944f82eb5995f48bdeab8c8627977578a569ffa73f56f56f id -u hc)
echo "hc uid: [$HCUID]"
case "$HCUID" in
''|*[!0-9]*) echo "STOP: the image answered with no uid" ;;
*) sudo chown -R "$HCUID:$(id -g)" data ;;
esac
ls -la
fi
```
Assert on Linux: `hc uid:` holds a number, `999` on this image, and `data` then belongs to it
with the user's own group. If `STOP:` printed, print what `docker run` said and stop: an empty
uid turns that chown into a group-only change that exits 0 and fixes nothing. macOS needs none
of it, Docker Desktop maps the mount to the uid the container asks for; Windows is on this
branch by now.
## 4. Secrets
One secret is generated here: the Django `SECRET_KEY`. Do not print it, repeat it in your
summary, or log it. Replace `you@example.com`, `smtp.example.net`, `587` and `relay-username`
with the four step 1 answers; `DEFAULT_FROM_EMAIL` and `EMAIL_HOST_USER` are not always one
string.
```bash
cd ~/selfhost/healthchecks
umask 077
cat > .env <<EOF
SECRET_KEY=$(openssl rand -base64 48)
SITE_ROOT=http://localhost:8088
SITE_NAME=Checks
ALLOWED_HOSTS=localhost,127.0.0.1
DB=sqlite
DB_NAME=/data/hc.sqlite
REGISTRATION_OPEN=True
DEFAULT_FROM_EMAIL=you@example.com
EMAIL_HOST=smtp.example.net
EMAIL_PORT=587
EMAIL_HOST_USER=relay-username
EMAIL_USE_TLS=True
EOF
chmod 600 .env
ls -l .env
```
Assert: mode `-rw-------`. `SITE_ROOT` is the base URL every absolute link is built from, so
every ping URL begins http://localhost:8088, the mechanism behind step 1's warning. Say this
once: copied onto a Windows drive, step 8's archive included, that mode means nothing; the
user's Windows account is the real boundary.
STOP: tell the user to open a second window of the shell they are in, Terminal on macOS or
Linux and a second Ubuntu window on Windows, and run the block below there, so the credential
never enters this session. Its last line waits with no prompt and echoes nothing: they type the
credential and press Return. It reads from that terminal, so nothing may be pasted after it.
```bash
cd ~/selfhost/healthchecks
umask 077
printf 'EMAIL_HOST_PASSWORD=' >> .env
read -rs && printf '%s\n' "$REPLY" >> .env && unset REPLY && chmod 600 .env
```
Then check it from this session:
```bash
awk -F= '/^EMAIL_HOST_PASSWORD/ {print "recorded, length " length($2)}' ~/selfhost/healthchecks/.env
```
Assert: a length greater than 0, and ask the user whether it matches their credential. Nothing
printed means the line is missing.
## 5. compose.yml
```bash
cat > ~/selfhost/healthchecks/compose.yml <<'EOF'
# Healthchecks · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# image and runtime notes . https://github.com/healthchecks/healthchecks/blob/master/docker/README.md
# configuration ........... https://healthchecks.io/docs/self_hosted_configuration/
#
# One container, no database process: the instance is one file at /data/hc.sqlite
# and uWSGI migrates 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, amd64
# and arm64. Paths are relative to this file.
#
# 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: ./.env
volumes:
# Owned by the hc uid on Linux, hence step 3. SQLite needs real POSIX locks,
# so this is a bind mount on a local Linux, macOS or WSL 2 filesystem only.
- ./data:/data
ports:
# Loopback only: nothing outside this computer can reach 8088.
- "127.0.0.1:8088:8000"
EOF
cd ~/selfhost/healthchecks && docker compose config >/dev/null && echo "compose OK"
```
Assert: `compose OK`. The container serves on 8000 inside itself; 8088 is bound to 127.0.0.1.
## 6. Nothing is public
Port 8088 is bound to 127.0.0.1, so no other device reaches it, the user's own phone included:
the shape of this path, not a defect. No DNS record and no certificate, there being nothing to
certify, and browsers treat `http://localhost` as a secure context, so page code needing crypto
works without TLS. No firewall rule: nothing from another machine reaches loopback, and mail
only goes out, through step 4's relay.
## 7. Start and verify
uWSGI migrates as it boots, so the first start writes hc.sqlite and is the slow one.
```bash
cd ~/selfhost/healthchecks
docker compose up -d
sleep 20
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8088/api/v3/status/
curl -sS http://localhost:8088/accounts/login/ | grep -c 'Log In to Checks'
curl -sS http://localhost:8088/accounts/login/ | grep -c 'id="signup-modal"'
```
Assert: the first prints `200` and the other two a number greater than 0; print all three. That
first URL is the image's own health check: 200 only when the database connection is alive. A
running container is not success. If any misses, stop, pull
`docker compose logs --tail 40 healthchecks`, and name the earlier step: a 500 on Linux is
usually step 3's ownership block; `database is locked` means `data` sits where locks are not
real.
The first screen at http://localhost:8088 is a log-in form headed `Log In to Checks`, with an
`Email Me a Link` button and a `Sign Up` link in the corner.
STOP: tell the user to open http://localhost:8088 in a browser on this computer, click
`Sign Up`, and enter the address from step 1. They read that mail here, not on their phone: the
sign-in link comes from `SITE_ROOT`, so it opens on this machine only. `/accounts/signup/`
answers POST only. Wait until they confirm they are signed in; if no mail arrives, check step
4's relay values first.
Once confirmed, close registration:
```bash
cd ~/selfhost/healthchecks
sed -i.bak 's/^REGISTRATION_OPEN=True$/REGISTRATION_OPEN=False/' .env
rm -f .env.bak
chmod 600 .env
docker compose up -d --force-recreate
sleep 20
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8088/api/v3/status/
curl -sS http://localhost:8088/accounts/login/ | grep -c 'id="signup-modal"' || true
```
Assert: the first prints `200`, the second `0`; print both. That `0` is the security assert:
`signup-modal` is the id of the sign-up form itself, so it counts the form gone, not wording
near it, and `grep -c` exits 1 counting nothing, hence `|| true`. The `.bak` holds the same
secrets, so it goes. Both asserts pass before you report success.
## 8. First backup and restore
Take the backup now, before the user adds a check, container stopped: a SQLite file copied
mid-write is not a backup.
```bash
cd ~/selfhost/healthchecks
docker compose stop
tar -czf backups/healthchecks-$(date +%F).tar.gz data .env
docker compose start
ls -lh backups/
```
Assert: the archive exists and is non-empty; print its size, a few hundred kilobytes fresh.
`data` plus `.env` is the whole install, relay credential included. If tar reports a permission
error on Linux, re-run step 3's ownership block.
That archive shares a disk with the data it protects; the two fail together. Ask the user
once for a folder that leaves this machine and `cp` the archive there: a sync service's folder,
iCloud Drive or Dropbox, or a USB stick, /Volumes/NAME on macOS, /media/NAME on Linux, /mnt/d
in the Ubuntu window. `ls -lh` the copy and print it. Do not finish until it exists in two
places.
To restore, from inside ~/selfhost/healthchecks: `docker compose down`, then `sudo rm -rf data`
on Linux and in the Ubuntu window or `rm -rf data` on macOS, which never chowned, then
`tar -xzf backups/<the archive>`, then step 3's two blocks again on Linux, then
`docker compose up -d`. Without that sudo the delete and the unpack are both refused: step 3
gave `data` to the hc uid at mode 750, which the user can read, hence the plain tar above, and
cannot write. Ping URLs live in `data/hc.sqlite`, so the ones in crontabs keep working.
## 9. Updating later
New versions are listed at https://github.com/healthchecks/healthchecks/releases. Back up
first, then edit compose.yml's image line to the new tag and digest. uWSGI migrates on the next
boot, so read the log until it settles.
```bash
cd ~/selfhost/healthchecks
docker compose pull
docker compose up -d
docker compose logs --tail 20 healthchecks
```
## 10. What will probably go wrong
The machine will sleep, and a monitor cannot tell sleeping apart from broken. I closed a laptop
lid at eleven and opened it at eight to a column of DOWN alerts for a nightly job that was never
late: the computer was off, so the job had not run and the check went overdue for it. Tell the
user that, and to set every check's grace period wider than the longest stretch this computer
sleeps.
## 11. Out of scope
- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not switch the database to PostgreSQL. SQLite is why this is one folder to copy.
- Do not set `SMTPD_PORT` or open an inbound mail listener. This install only sends.
- Do not add a cron job for alerts. uWSGI keeps `sendalerts` running already.compose.local.ymlthe services, pinned · local layout25 lines
# Healthchecks · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# image and runtime notes . https://github.com/healthchecks/healthchecks/blob/master/docker/README.md
# configuration ........... https://healthchecks.io/docs/self_hosted_configuration/
#
# One container, no database process: the instance is one file at /data/hc.sqlite
# and uWSGI migrates 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, amd64
# and arm64. Paths are relative to this file.
#
# 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: ./.env
volumes:
# Owned by the hc uid on Linux, hence step 3. SQLite needs real POSIX locks,
# so this is a bind mount on a local Linux, macOS or WSL 2 filesystem only.
- ./data:/data
ports:
# Loopback only: nothing outside this computer can reach 8088.
- "127.0.0.1:8088:8000"agent-readable mirror: /self-host/cronitor.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned25 lines
# 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
# 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
#!/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.
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 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.
Can I run Healthchecks on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same Healthchecks on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Nothing outside this computer can reach a localhost ping URL, so this instance can only watch cron jobs that run on this same machine, and the jobs on your servers will have to report somewhere else. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-05. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.