Vibecode Cronitor
track this build5 phases, 19 steps, beginner friendly0%Heartbeat endpoint plus dead-man's-switch alerts. A hundred lines of code wearing a SaaS badge.
You are building a lean indie version of Cronitor.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== README.md =====
# Cronitor · indie build
A cron-job monitor you run yourself: every scheduled job gets a URL it pings when it finishes, the monitor flips a job to late and then down when the pings stop, one chat message goes out per state change, and a dashboard shows every job with its last ping and a 24-hour histogram. When every item is ticked you have Healthchecks-style monitoring for a few dollars of hosting.
Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts.
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Runtime | Node 22, node:http and node:sqlite | no framework and no ORM: a ping is one INSERT and the loop is a setInterval |
| Database | SQLite in WAL mode | one file, survives restarts, fast enough for millions of pings |
| Alerts | One chat webhook | the channel you already watch, no SMS provider bill |
| Hosting | A VPS that is not the box running your jobs | a monitor on the same host reports nothing when that host dies |
## Before you start
Have every one of these ready. The plan assumes them from step one.
- [ ] **Node.js 22 or newer** · free
- Why: Everything in this build runs on it: the server, the scripts, the tests.
- Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards.
- Verify: node --version prints v22 or higher
- [ ] **A terminal and a code editor** · free
- Why: Every step below is a command you type or a file you edit.
- Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal.
- Verify: You can open a folder and run a command in its terminal
- [ ] **Git** · free
- Why: History for your code, and the way most hosts deploy.
- Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists.
- Verify: git --version prints a version
- [ ] **A chat webhook URL (Discord, Slack or Telegram)** · free
- Why: Alerts go to a chat channel you already watch. A webhook URL is the only credential this needs.
- Get it: Discord: Server settings > Integrations > Webhooks > New Webhook, copy the URL. Slack: create an app at api.slack.com/apps, enable Incoming Webhooks, add to a channel, copy the URL. Telegram: create a bot with @BotFather and use the bot token plus your chat id.
- Verify: curl -X POST -H 'Content-Type: application/json' -d '{"content":"test"}' <url> posts a message (Discord form; Slack uses a text field)
- [ ] **The list of jobs you want watched** · free
- Why: Each job needs a name, how often it runs, and how late is too late. Deciding this up front is what makes Phase 2 testable.
- Get it: Run crontab -l on each machine and write down every job: name, schedule (every 5 minutes, hourly, nightly at 03:00), and a grace period (how long past due before you want to be told).
- [ ] **curl on the machines running the jobs** · free
- Why: The whole integration is appending && curl -fsS <url> to a crontab line.
- Get it: Already present on nearly every Linux and macOS system.
- Verify: curl --version prints a version
- [ ] **A small always-on server (VPS)** (optional) · about $5 a month
- Why: This needs one process running all the time with a public address. The monitor must live somewhere other than the machines it watches.
- Get it: Hetzner Cloud (from about 4 EUR), DigitalOcean or Fly.io. Ubuntu 24.04, the smallest size. You need SSH access and a public IP. Only needed for the deploy phase; develop locally first.
- [ ] **A domain or subdomain** (optional) · roughly $10 a year, or free on an existing domain
- Why: A stable address for ping URLs, so a server move does not mean editing every crontab.
- Get it: Register at Cloudflare Registrar, Porkbun or Namecheap, or use a subdomain of one you already own. You add one DNS record in the deploy phase.
- [ ] **Caddy on the server** (optional) · free
- Why: Automatic HTTPS in front of the Node process. Without TLS the browser features this relies on (and your visitors' trust) do not work.
- Get it: On the VPS: follow the install steps at caddyserver.com/docs/install for Ubuntu. One Caddyfile with your domain and a reverse_proxy line is the whole config.
- Verify: caddy version prints a version on the server
## Quick start
```sh
mkdir cron-monitor && cd cron-monitor && git init && npm init -y && npm pkg set type=module
mkdir data
cp .env.example .env
```
Then copy `.env.example` to `.env` and fill in the values it documents.
## Honest limits
This build deliberately does not replace:
- SMS, WhatsApp and phone-call alerts. One webhook into a chat app covers the solo case; a provider bill covers the rest.
- Status pages, teams and on-call rotation. That is the paid product.
- Cron-expression parsing and insights. Period plus grace is enough and it is the part you can get right.
- their status pages and team features
- alert routing (SMS, PagerDuty)
- cron expression insights
If one of those is essential to you, that is the reason to keep paying for Cronitor, and the README should say so rather than pretend.
===== BRIEF.md =====
# Build brief · Cronitor
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a cron-job monitor like Cronitor or Healthchecks. Build it in phases,
in the order below. Do not write the whole service in one pass. Finish a phase,
run its "Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- Node 22 with `node:http` and `node:sqlite`, or Python 3.12 with stdlib
`http.server` and `sqlite3`. Pick one, no web framework.
- One process: HTTP server and scheduler loop in the same process.
- SQLite file at a path from `.env`. No ORM.
### Data model (create this before Phase 1)
- `checks`: id (uuid), name, period_seconds, grace_seconds, status
('new' | 'up' | 'late' | 'down' | 'paused'), last_ping_at, last_started_at,
last_duration_ms, created_at
- `pings`: id, check_id, received_at, kind ('start' | 'success' | 'fail' | 'log'),
exit_code, body (capped, see Phase 1), user_agent, remote_ip
- `alerts`: id, check_id, from_status, to_status, sent_at, delivered (bool), error
Index `pings(check_id, received_at)`. Every timestamp is UTC epoch milliseconds ·
never a local-time string, or the late/down maths silently breaks across a DST
boundary.
### Phase 1 · Ping ingestion
Build: the ping endpoints, matching the Healthchecks scheme so existing crontab
snippets port over unchanged.
- `GET|POST|HEAD /ping/:uuid` · success
- `/ping/:uuid/start` · job began, records last_started_at for duration
- `/ping/:uuid/fail` · explicit failure
- `/ping/:uuid/:exit_code` · 0 is success, 1-255 is failure
- `/ping/:uuid/log` · records a line without changing status
Accept HEAD, GET and POST. Store at most the first 100 kB of a POST body and
truncate silently beyond that. Always answer `200` with the body `OK`. An unknown
uuid answers `404` with `not found`. A ping must never fail because the database
is busy · use WAL mode and a short busy timeout.
Done when: `curl -fsS localhost:PORT/ping/<uuid>` prints `OK` and inserts one row;
posting a 2 MB body stores exactly 100 kB and still returns 200; and a ping to a
random uuid returns 404 without creating anything.
Do not build yet: status transitions, alerts, any UI.
### Phase 2 · Status state machine
Build: the scheduler loop, running every 30 seconds, that recomputes status.
- A check is `up` while `now <= last_ping_at + period`.
- It becomes `late` when `now > last_ping_at + period`.
- It becomes `down` when `now > last_ping_at + period + grace`.
- An explicit fail ping sets `down` immediately, whatever the timing.
- A success ping sets `up` immediately.
- `new` checks (never pinged) do not alert · they wait for a first ping.
- `paused` checks are skipped entirely.
Every transition writes one `alerts` row. Compute duration from a `/start`
followed by a success as `last_duration_ms`.
Done when: a check with period 60s and grace 30s reads `up` right after a ping,
`late` at 61s, and `down` at 91s, with exactly one alerts row per transition and
none for a repeated poll in the same state.
Do not build yet: sending anything.
### Phase 3 · Alerting
Build: a webhook sender (Discord, Slack or Telegram URL in `.env`) that drains
undelivered `alerts` rows. One message per state change · never per poll. Message
carries the check name, the new status, how late it is in human units ("14 min
late"), and the last failure body when there is one. Send a recovery message on
the return to `up`. Retry a failed delivery three times with exponential backoff,
then mark the row with its error and move on; a dead webhook must never stall
the loop or lose a later alert.
Done when: taking a check down produces exactly one message, leaving it down
produces no further messages, and bringing it back produces exactly one recovery
message. Pointing the webhook at a URL that 500s three times leaves a row with an
error and the loop still running.
### Phase 4 · Admin dashboard
Build: `/admin` behind basic auth from `.env`, with CRUD for checks (name, period,
grace, pause) and, per check, its ping URL with a copy button, a crontab example
line, a green/amber/red status dot, a relative last-ping time ("7 min ago"), and a
24-hour ping histogram drawn as inline SVG. No chart library, no client framework.
Done when: a check can be created, edited, paused and deleted from the browser;
the page renders correctly with zero checks; and the histogram matches the row
count in the database for the last 24 hours.
### Phase 5 · Hardening and deploy
Build: per-IP rate limiting on the ping route (generous · a legitimate job may
ping every minute), a `/healthz` endpoint, a pings retention job that deletes rows
older than a configurable number of days, a systemd unit, and the README.
Done when: the service survives a restart with state intact, retention actually
deletes, and the README takes a reader from clone to a monitored cron job.
### Out of scope (and why)
- Public status pages, teams, and on-call rotation. That is the paid product.
- SMS and PagerDuty routing · you would be paying a provider anyway, and one
webhook into a chat app covers the solo case.
- Cron-expression parsing and "insights". Period plus grace is enough, and it is
the part you can get right.
### README must contain
- The crontab one-liner: `*/5 * * * * /path/job.sh && curl -fsS <ping-url>`
- The wrapper form that reports failures too, using `/start` and `/fail`.
- A warning that a monitor on the same host as the jobs dies with the host · say
plainly that this is why hosted monitoring exists.
===== AGENTS.md =====
# Agent instructions · Cronitor indie build
- Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22, node:http and node:sqlite, SQLite in WAL mode, One chat webhook, A VPS that is not the box running your jobs. Do not substitute.
- Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes.
- Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name.
- Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced.
- Do not invent cryptography, security guarantees, APIs or compliance claims.
- Add a focused test for every destructive, security-sensitive or data-loss path the plan names.
- Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs".
## Known traps
- Store times as UTC epoch milliseconds, never local-time strings. The late maths breaks across a DST change otherwise.
- Do not validate the body. Jobs post arbitrary output; you store it and show it.
- Test the state machine with a fake clock before touching the webhook. Waiting real minutes to see a transition is how this phase eats an evening.
- Put the monitor on a different machine than the jobs. On the same host it dies with the host, which is precisely when it was supposed to speak.
===== BUILD_PLAN.md =====
# Build plan · Cronitor
A cron-job monitor you run yourself: every scheduled job gets a URL it pings when it finishes, the monitor flips a job to late and then down when the pings stop, one chat message goes out per state change, and a dashboard shows every job with its last ping and a 24-hour histogram. When every item is ticked you have Healthchecks-style monitoring for a few dollars of hosting.
Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note.
## Phase 1 · Ping ingestion
Accept pings on URLs that match the Healthchecks scheme, store them, and never fail a ping because the database is busy.
### Steps
1. Create the project, the database file and the two tables
checks (id uuid, name, period_seconds, grace_seconds, status, last_ping_at, last_started_at, last_duration_ms) and pings (id, check_id, received_at, kind, exit_code, body). All timestamps as UTC epoch milliseconds. Open SQLite with WAL mode and a busy timeout.
Files: `server.mjs`, `db.mjs`, `.env`
```sh
mkdir cron-monitor && cd cron-monitor && git init && npm init -y && npm pkg set type=module
mkdir data
cp .env.example .env
```
2. Route /ping/:uuid and its variants
GET, POST and HEAD on /ping/:uuid is a success. /ping/:uuid/start records a start, /ping/:uuid/fail a failure, /ping/:uuid/log a log line, and /ping/:uuid/<0-255> treats 0 as success and anything else as failure.
3. Store at most 100 kB of a POST body and always answer OK
Read the body up to 100 kB and stop reading. Answer 200 with the plain text OK. An unknown uuid answers 404 with not found and inserts nothing.
4. Add a create-check script for testing
A tiny CLI that inserts a check row with a name, period and grace and prints its ping URL, so you can test before the dashboard exists.
```sh
node scripts/add-check.mjs "nightly backup" 86400 1800
```
### Done when
- [ ] curl -fsS http://localhost:3000/ping/<uuid> prints OK and one row appears in pings
- [ ] Posting a 2 MB body stores exactly 100 kB and still returns 200
- [ ] A ping to a random uuid returns 404 and creates nothing
- [ ] /ping/<uuid>/7 stores kind fail with exit_code 7
### Watch out
- Store times as UTC epoch milliseconds, never local-time strings. The late maths breaks across a DST change otherwise.
- Do not validate the body. Jobs post arbitrary output; you store it and show it.
## Phase 2 · Status state machine
A loop that turns pings into up, late and down, alerts exactly once per transition, and never alerts for a check that has not pinged yet.
### Steps
1. Write the evaluation function
For each check: up while now is within period of last_ping_at; late once past period; down once past period plus grace. A fail ping sets down at once; any success sets up at once. Status new (never pinged) and paused are skipped.
2. Record every transition in an alerts table
alerts (id, check_id, from_status, to_status, sent_at, delivered, error). Write a row when status changes and only then.
3. Compute run duration from /start to success
When a success follows a start, set last_duration_ms. Show it later so a job that suddenly takes 40 minutes is visible.
4. Run the loop on CHECK_INTERVAL_SECONDS in the same process
setInterval, wrapped in try/catch so one bad row cannot stop the loop. Log one line per transition.
### Done when
- [ ] A check with period 60 and grace 30 reads up right after a ping, late at 61 seconds and down at 91
- [ ] Exactly one alerts row per transition and none for a repeated loop in the same state
- [ ] A fail ping flips a check to down immediately
- [ ] A new check with no pings never gets an alerts row
### Watch out
- Test the state machine with a fake clock before touching the webhook. Waiting real minutes to see a transition is how this phase eats an evening.
## Phase 3 · Alerting
One chat message when a job goes down, one when it recovers, retries that never stall the loop.
### Steps
1. Write a sender for your ALERT_FORMAT
Discord wants {content}, Slack wants {text}, Telegram wants chat_id and text on the bot API. Include the check name, the new status, how late in human units (14 min late) and the last failure body if any.
2. Drain undelivered alerts rows after each loop
Send, mark delivered on 2xx. On failure retry three times with backoff (2 s, 10 s, 60 s), then record the error on the row and move on.
3. Send a recovery message on the return to up
It carries how long the outage lasted, from the down alert's sent_at.
### Done when
- [ ] Taking a check down produces exactly one message in the channel
- [ ] Leaving it down produces no further messages
- [ ] Recovery produces exactly one message with a duration
- [ ] Pointing ALERT_WEBHOOK_URL at a URL that returns 500 leaves an alerts row with an error and the loop still running
## Phase 4 · Admin dashboard
Create and manage checks in the browser, and see every job's state at a glance.
### Steps
1. Add basic auth from ADMIN_USER and ADMIN_PASS
Compare with a constant-time function. Everything under /admin requires it; /ping never does.
2. Build the check list
One row per check: a green, amber or red dot, name, relative last ping (7 min ago), period and grace, and the ping URL with a copy button plus a ready-to-paste crontab example line.
3. Add create, edit, pause and delete forms
Plain HTML forms posting to /admin routes. No JavaScript required for any of them.
4. Draw a 24-hour ping histogram per check as inline SVG
One bar per hour from a GROUP BY on received_at. No chart library.
### Done when
- [ ] A check can be created, edited, paused and deleted from the browser
- [ ] The page renders correctly with zero checks
- [ ] The histogram bars match a count query for the last 24 hours
- [ ] The ping URL copied from the dashboard works in curl
## Phase 5 · Hardening and deploy
Live on your VPS behind HTTPS, surviving restarts, with old pings pruned.
### Steps
1. Rate limit /ping per IP, generously
A legitimate job may ping every minute; allow 120 per minute per IP in memory and answer 429 beyond that.
2. Add /healthz and a nightly retention job
/healthz answers 200 with a quick database read. Retention deletes pings older than RETENTION_DAYS once a day; alerts and checks are never pruned.
3. Install on the VPS with systemd and Caddy
Unit with Restart=on-failure, EnvironmentFile=.env, an unprivileged user. Caddyfile: your domain with reverse_proxy localhost:PORT.
Files: `deploy/monitor.service`, `Caddyfile`
```sh
sudo cp deploy/monitor.service /etc/systemd/system/ && sudo systemctl enable --now monitor
sudo systemctl status monitor
```
4. Add the ping to one real crontab and write the README
README: the one-liner (*/5 * * * * /path/job.sh && curl -fsS <url>), the wrapper form that reports failures with /start and /fail, and the same-host warning.
Files: `README.md`
```sh
crontab -e
# 0 3 * * * /home/you/backup.sh && curl -fsS https://ping.yourdomain.com/ping/<uuid>
```
### Done when
- [ ] The service comes back with state intact after sudo reboot
- [ ] Retention actually deletes pings older than the window
- [ ] A real cron job on another machine shows up as up on the dashboard
- [ ] The README takes a reader from clone to a monitored job
### Watch out
- Put the monitor on a different machine than the jobs. On the same host it dies with the host, which is precisely when it was supposed to speak.
## Not in this build
- SMS, WhatsApp and phone-call alerts. One webhook into a chat app covers the solo case; a provider bill covers the rest.
- Status pages, teams and on-call rotation. That is the paid product.
- Cron-expression parsing and insights. Period plus grace is enough and it is the part you can get right.
## After v1, if you want it
- A second alert channel (email over SMTP) behind the same Notifier interface
- A public read-only status page rendered from the checks table
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. Any free port. Caddy proxies to it on the server.
PORT=3000
# Required. Where the SQLite file lives. Create the data/ folder; back this file up.
DATABASE_PATH=./data/monitor.db
# Required · secret. The chat webhook from the prerequisites.
ALERT_WEBHOOK_URL=https://discord.com/api/webhooks/...
# Required. discord, slack or telegram. Decides the JSON shape of the message.
ALERT_FORMAT=discord
# Required. Any username for the basic-auth admin pages.
ADMIN_USER=admin
# Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password.
ADMIN_PASS=change-me-to-a-long-random-string
# Required. Public base URL, used to print the ping URLs in the dashboard.
SITE_URL=https://ping.yourdomain.com
# Optional. How long to keep individual pings. Alerts and checks are kept forever.
RETENTION_DAYS=30
# Optional. How often the loop re-evaluates every check. 30 is plenty.
CHECK_INTERVAL_SECONDS=30
You are building a lean indie version of Cronitor.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== README.md =====
# Cronitor · indie build
A cron-job monitor you run yourself: every scheduled job gets a URL it pings when it finishes, the monitor flips a job to late and then down when the pings stop, one chat message goes out per state change, and a dashboard shows every job with its last ping and a 24-hour histogram. When every item is ticked you have Healthchecks-style monitoring for a few dollars of hosting.
Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts.
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Runtime | Node 22, node:http and node:sqlite | no framework and no ORM: a ping is one INSERT and the loop is a setInterval |
| Database | SQLite in WAL mode | one file, survives restarts, fast enough for millions of pings |
| Alerts | One chat webhook | the channel you already watch, no SMS provider bill |
| Hosting | A VPS that is not the box running your jobs | a monitor on the same host reports nothing when that host dies |
## Before you start
Have every one of these ready. The plan assumes them from step one.
- [ ] **Node.js 22 or newer** · free
- Why: Everything in this build runs on it: the server, the scripts, the tests.
- Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards.
- Verify: node --version prints v22 or higher
- [ ] **A terminal and a code editor** · free
- Why: Every step below is a command you type or a file you edit.
- Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal.
- Verify: You can open a folder and run a command in its terminal
- [ ] **Git** · free
- Why: History for your code, and the way most hosts deploy.
- Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists.
- Verify: git --version prints a version
- [ ] **A chat webhook URL (Discord, Slack or Telegram)** · free
- Why: Alerts go to a chat channel you already watch. A webhook URL is the only credential this needs.
- Get it: Discord: Server settings > Integrations > Webhooks > New Webhook, copy the URL. Slack: create an app at api.slack.com/apps, enable Incoming Webhooks, add to a channel, copy the URL. Telegram: create a bot with @BotFather and use the bot token plus your chat id.
- Verify: curl -X POST -H 'Content-Type: application/json' -d '{"content":"test"}' <url> posts a message (Discord form; Slack uses a text field)
- [ ] **The list of jobs you want watched** · free
- Why: Each job needs a name, how often it runs, and how late is too late. Deciding this up front is what makes Phase 2 testable.
- Get it: Run crontab -l on each machine and write down every job: name, schedule (every 5 minutes, hourly, nightly at 03:00), and a grace period (how long past due before you want to be told).
- [ ] **curl on the machines running the jobs** · free
- Why: The whole integration is appending && curl -fsS <url> to a crontab line.
- Get it: Already present on nearly every Linux and macOS system.
- Verify: curl --version prints a version
- [ ] **A small always-on server (VPS)** (optional) · about $5 a month
- Why: This needs one process running all the time with a public address. The monitor must live somewhere other than the machines it watches.
- Get it: Hetzner Cloud (from about 4 EUR), DigitalOcean or Fly.io. Ubuntu 24.04, the smallest size. You need SSH access and a public IP. Only needed for the deploy phase; develop locally first.
- [ ] **A domain or subdomain** (optional) · roughly $10 a year, or free on an existing domain
- Why: A stable address for ping URLs, so a server move does not mean editing every crontab.
- Get it: Register at Cloudflare Registrar, Porkbun or Namecheap, or use a subdomain of one you already own. You add one DNS record in the deploy phase.
- [ ] **Caddy on the server** (optional) · free
- Why: Automatic HTTPS in front of the Node process. Without TLS the browser features this relies on (and your visitors' trust) do not work.
- Get it: On the VPS: follow the install steps at caddyserver.com/docs/install for Ubuntu. One Caddyfile with your domain and a reverse_proxy line is the whole config.
- Verify: caddy version prints a version on the server
## Quick start
```sh
mkdir cron-monitor && cd cron-monitor && git init && npm init -y && npm pkg set type=module
mkdir data
cp .env.example .env
```
Then copy `.env.example` to `.env` and fill in the values it documents.
## Honest limits
This build deliberately does not replace:
- SMS, WhatsApp and phone-call alerts. One webhook into a chat app covers the solo case; a provider bill covers the rest.
- Status pages, teams and on-call rotation. That is the paid product.
- Cron-expression parsing and insights. Period plus grace is enough and it is the part you can get right.
- their status pages and team features
- alert routing (SMS, PagerDuty)
- cron expression insights
If one of those is essential to you, that is the reason to keep paying for Cronitor, and the README should say so rather than pretend.
===== BRIEF.md =====
# Build brief · Cronitor
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a cron-job monitor like Cronitor or Healthchecks. Build it in phases,
in the order below. Do not write the whole service in one pass. Finish a phase,
run its "Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- Node 22 with `node:http` and `node:sqlite`, or Python 3.12 with stdlib
`http.server` and `sqlite3`. Pick one, no web framework.
- One process: HTTP server and scheduler loop in the same process.
- SQLite file at a path from `.env`. No ORM.
### Data model (create this before Phase 1)
- `checks`: id (uuid), name, period_seconds, grace_seconds, status
('new' | 'up' | 'late' | 'down' | 'paused'), last_ping_at, last_started_at,
last_duration_ms, created_at
- `pings`: id, check_id, received_at, kind ('start' | 'success' | 'fail' | 'log'),
exit_code, body (capped, see Phase 1), user_agent, remote_ip
- `alerts`: id, check_id, from_status, to_status, sent_at, delivered (bool), error
Index `pings(check_id, received_at)`. Every timestamp is UTC epoch milliseconds ·
never a local-time string, or the late/down maths silently breaks across a DST
boundary.
### Phase 1 · Ping ingestion
Build: the ping endpoints, matching the Healthchecks scheme so existing crontab
snippets port over unchanged.
- `GET|POST|HEAD /ping/:uuid` · success
- `/ping/:uuid/start` · job began, records last_started_at for duration
- `/ping/:uuid/fail` · explicit failure
- `/ping/:uuid/:exit_code` · 0 is success, 1-255 is failure
- `/ping/:uuid/log` · records a line without changing status
Accept HEAD, GET and POST. Store at most the first 100 kB of a POST body and
truncate silently beyond that. Always answer `200` with the body `OK`. An unknown
uuid answers `404` with `not found`. A ping must never fail because the database
is busy · use WAL mode and a short busy timeout.
Done when: `curl -fsS localhost:PORT/ping/<uuid>` prints `OK` and inserts one row;
posting a 2 MB body stores exactly 100 kB and still returns 200; and a ping to a
random uuid returns 404 without creating anything.
Do not build yet: status transitions, alerts, any UI.
### Phase 2 · Status state machine
Build: the scheduler loop, running every 30 seconds, that recomputes status.
- A check is `up` while `now <= last_ping_at + period`.
- It becomes `late` when `now > last_ping_at + period`.
- It becomes `down` when `now > last_ping_at + period + grace`.
- An explicit fail ping sets `down` immediately, whatever the timing.
- A success ping sets `up` immediately.
- `new` checks (never pinged) do not alert · they wait for a first ping.
- `paused` checks are skipped entirely.
Every transition writes one `alerts` row. Compute duration from a `/start`
followed by a success as `last_duration_ms`.
Done when: a check with period 60s and grace 30s reads `up` right after a ping,
`late` at 61s, and `down` at 91s, with exactly one alerts row per transition and
none for a repeated poll in the same state.
Do not build yet: sending anything.
### Phase 3 · Alerting
Build: a webhook sender (Discord, Slack or Telegram URL in `.env`) that drains
undelivered `alerts` rows. One message per state change · never per poll. Message
carries the check name, the new status, how late it is in human units ("14 min
late"), and the last failure body when there is one. Send a recovery message on
the return to `up`. Retry a failed delivery three times with exponential backoff,
then mark the row with its error and move on; a dead webhook must never stall
the loop or lose a later alert.
Done when: taking a check down produces exactly one message, leaving it down
produces no further messages, and bringing it back produces exactly one recovery
message. Pointing the webhook at a URL that 500s three times leaves a row with an
error and the loop still running.
### Phase 4 · Admin dashboard
Build: `/admin` behind basic auth from `.env`, with CRUD for checks (name, period,
grace, pause) and, per check, its ping URL with a copy button, a crontab example
line, a green/amber/red status dot, a relative last-ping time ("7 min ago"), and a
24-hour ping histogram drawn as inline SVG. No chart library, no client framework.
Done when: a check can be created, edited, paused and deleted from the browser;
the page renders correctly with zero checks; and the histogram matches the row
count in the database for the last 24 hours.
### Phase 5 · Hardening and deploy
Build: per-IP rate limiting on the ping route (generous · a legitimate job may
ping every minute), a `/healthz` endpoint, a pings retention job that deletes rows
older than a configurable number of days, a systemd unit, and the README.
Done when: the service survives a restart with state intact, retention actually
deletes, and the README takes a reader from clone to a monitored cron job.
### Out of scope (and why)
- Public status pages, teams, and on-call rotation. That is the paid product.
- SMS and PagerDuty routing · you would be paying a provider anyway, and one
webhook into a chat app covers the solo case.
- Cron-expression parsing and "insights". Period plus grace is enough, and it is
the part you can get right.
### README must contain
- The crontab one-liner: `*/5 * * * * /path/job.sh && curl -fsS <ping-url>`
- The wrapper form that reports failures too, using `/start` and `/fail`.
- A warning that a monitor on the same host as the jobs dies with the host · say
plainly that this is why hosted monitoring exists.
===== AGENTS.md =====
# Agent instructions · Cronitor indie build
- Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22, node:http and node:sqlite, SQLite in WAL mode, One chat webhook, A VPS that is not the box running your jobs. Do not substitute.
- Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes.
- Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name.
- Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced.
- Do not invent cryptography, security guarantees, APIs or compliance claims.
- Add a focused test for every destructive, security-sensitive or data-loss path the plan names.
- Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs".
## Known traps
- Store times as UTC epoch milliseconds, never local-time strings. The late maths breaks across a DST change otherwise.
- Do not validate the body. Jobs post arbitrary output; you store it and show it.
- Test the state machine with a fake clock before touching the webhook. Waiting real minutes to see a transition is how this phase eats an evening.
- Put the monitor on a different machine than the jobs. On the same host it dies with the host, which is precisely when it was supposed to speak.
===== BUILD_PLAN.md =====
# Build plan · Cronitor
A cron-job monitor you run yourself: every scheduled job gets a URL it pings when it finishes, the monitor flips a job to late and then down when the pings stop, one chat message goes out per state change, and a dashboard shows every job with its last ping and a 24-hour histogram. When every item is ticked you have Healthchecks-style monitoring for a few dollars of hosting.
Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note.
## Phase 1 · Ping ingestion
Accept pings on URLs that match the Healthchecks scheme, store them, and never fail a ping because the database is busy.
### Steps
1. Create the project, the database file and the two tables
checks (id uuid, name, period_seconds, grace_seconds, status, last_ping_at, last_started_at, last_duration_ms) and pings (id, check_id, received_at, kind, exit_code, body). All timestamps as UTC epoch milliseconds. Open SQLite with WAL mode and a busy timeout.
Files: `server.mjs`, `db.mjs`, `.env`
```sh
mkdir cron-monitor && cd cron-monitor && git init && npm init -y && npm pkg set type=module
mkdir data
cp .env.example .env
```
2. Route /ping/:uuid and its variants
GET, POST and HEAD on /ping/:uuid is a success. /ping/:uuid/start records a start, /ping/:uuid/fail a failure, /ping/:uuid/log a log line, and /ping/:uuid/<0-255> treats 0 as success and anything else as failure.
3. Store at most 100 kB of a POST body and always answer OK
Read the body up to 100 kB and stop reading. Answer 200 with the plain text OK. An unknown uuid answers 404 with not found and inserts nothing.
4. Add a create-check script for testing
A tiny CLI that inserts a check row with a name, period and grace and prints its ping URL, so you can test before the dashboard exists.
```sh
node scripts/add-check.mjs "nightly backup" 86400 1800
```
### Done when
- [ ] curl -fsS http://localhost:3000/ping/<uuid> prints OK and one row appears in pings
- [ ] Posting a 2 MB body stores exactly 100 kB and still returns 200
- [ ] A ping to a random uuid returns 404 and creates nothing
- [ ] /ping/<uuid>/7 stores kind fail with exit_code 7
### Watch out
- Store times as UTC epoch milliseconds, never local-time strings. The late maths breaks across a DST change otherwise.
- Do not validate the body. Jobs post arbitrary output; you store it and show it.
## Phase 2 · Status state machine
A loop that turns pings into up, late and down, alerts exactly once per transition, and never alerts for a check that has not pinged yet.
### Steps
1. Write the evaluation function
For each check: up while now is within period of last_ping_at; late once past period; down once past period plus grace. A fail ping sets down at once; any success sets up at once. Status new (never pinged) and paused are skipped.
2. Record every transition in an alerts table
alerts (id, check_id, from_status, to_status, sent_at, delivered, error). Write a row when status changes and only then.
3. Compute run duration from /start to success
When a success follows a start, set last_duration_ms. Show it later so a job that suddenly takes 40 minutes is visible.
4. Run the loop on CHECK_INTERVAL_SECONDS in the same process
setInterval, wrapped in try/catch so one bad row cannot stop the loop. Log one line per transition.
### Done when
- [ ] A check with period 60 and grace 30 reads up right after a ping, late at 61 seconds and down at 91
- [ ] Exactly one alerts row per transition and none for a repeated loop in the same state
- [ ] A fail ping flips a check to down immediately
- [ ] A new check with no pings never gets an alerts row
### Watch out
- Test the state machine with a fake clock before touching the webhook. Waiting real minutes to see a transition is how this phase eats an evening.
## Phase 3 · Alerting
One chat message when a job goes down, one when it recovers, retries that never stall the loop.
### Steps
1. Write a sender for your ALERT_FORMAT
Discord wants {content}, Slack wants {text}, Telegram wants chat_id and text on the bot API. Include the check name, the new status, how late in human units (14 min late) and the last failure body if any.
2. Drain undelivered alerts rows after each loop
Send, mark delivered on 2xx. On failure retry three times with backoff (2 s, 10 s, 60 s), then record the error on the row and move on.
3. Send a recovery message on the return to up
It carries how long the outage lasted, from the down alert's sent_at.
### Done when
- [ ] Taking a check down produces exactly one message in the channel
- [ ] Leaving it down produces no further messages
- [ ] Recovery produces exactly one message with a duration
- [ ] Pointing ALERT_WEBHOOK_URL at a URL that returns 500 leaves an alerts row with an error and the loop still running
## Phase 4 · Admin dashboard
Create and manage checks in the browser, and see every job's state at a glance.
### Steps
1. Add basic auth from ADMIN_USER and ADMIN_PASS
Compare with a constant-time function. Everything under /admin requires it; /ping never does.
2. Build the check list
One row per check: a green, amber or red dot, name, relative last ping (7 min ago), period and grace, and the ping URL with a copy button plus a ready-to-paste crontab example line.
3. Add create, edit, pause and delete forms
Plain HTML forms posting to /admin routes. No JavaScript required for any of them.
4. Draw a 24-hour ping histogram per check as inline SVG
One bar per hour from a GROUP BY on received_at. No chart library.
### Done when
- [ ] A check can be created, edited, paused and deleted from the browser
- [ ] The page renders correctly with zero checks
- [ ] The histogram bars match a count query for the last 24 hours
- [ ] The ping URL copied from the dashboard works in curl
## Phase 5 · Hardening and deploy
Live on your VPS behind HTTPS, surviving restarts, with old pings pruned.
### Steps
1. Rate limit /ping per IP, generously
A legitimate job may ping every minute; allow 120 per minute per IP in memory and answer 429 beyond that.
2. Add /healthz and a nightly retention job
/healthz answers 200 with a quick database read. Retention deletes pings older than RETENTION_DAYS once a day; alerts and checks are never pruned.
3. Install on the VPS with systemd and Caddy
Unit with Restart=on-failure, EnvironmentFile=.env, an unprivileged user. Caddyfile: your domain with reverse_proxy localhost:PORT.
Files: `deploy/monitor.service`, `Caddyfile`
```sh
sudo cp deploy/monitor.service /etc/systemd/system/ && sudo systemctl enable --now monitor
sudo systemctl status monitor
```
4. Add the ping to one real crontab and write the README
README: the one-liner (*/5 * * * * /path/job.sh && curl -fsS <url>), the wrapper form that reports failures with /start and /fail, and the same-host warning.
Files: `README.md`
```sh
crontab -e
# 0 3 * * * /home/you/backup.sh && curl -fsS https://ping.yourdomain.com/ping/<uuid>
```
### Done when
- [ ] The service comes back with state intact after sudo reboot
- [ ] Retention actually deletes pings older than the window
- [ ] A real cron job on another machine shows up as up on the dashboard
- [ ] The README takes a reader from clone to a monitored job
### Watch out
- Put the monitor on a different machine than the jobs. On the same host it dies with the host, which is precisely when it was supposed to speak.
## Not in this build
- SMS, WhatsApp and phone-call alerts. One webhook into a chat app covers the solo case; a provider bill covers the rest.
- Status pages, teams and on-call rotation. That is the paid product.
- Cron-expression parsing and insights. Period plus grace is enough and it is the part you can get right.
## After v1, if you want it
- A second alert channel (email over SMTP) behind the same Notifier interface
- A public read-only status page rendered from the checks table
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. Any free port. Caddy proxies to it on the server.
PORT=3000
# Required. Where the SQLite file lives. Create the data/ folder; back this file up.
DATABASE_PATH=./data/monitor.db
# Required · secret. The chat webhook from the prerequisites.
ALERT_WEBHOOK_URL=https://discord.com/api/webhooks/...
# Required. discord, slack or telegram. Decides the JSON shape of the message.
ALERT_FORMAT=discord
# Required. Any username for the basic-auth admin pages.
ADMIN_USER=admin
# Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password.
ADMIN_PASS=change-me-to-a-long-random-string
# Required. Public base URL, used to print the ping URLs in the dashboard.
SITE_URL=https://ping.yourdomain.com
# Optional. How long to keep individual pings. Alerts and checks are kept forever.
RETENTION_DAYS=30
# Optional. How often the loop re-evaluates every check. 30 is plenty.
CHECK_INTERVAL_SECONDS=30
You are building a production product version of Cronitor.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== PRODUCT.md =====
# Cronitor · product brief
## Problem
Heartbeat endpoint plus dead-man's-switch alerts. A hundred lines of code wearing a SaaS badge.
## Product outcome
A monitoring service others could rely on: every job watched from a box that is not theirs, alerts that fire once and recover, and the monitor itself monitored and backed up.
## Target user
A builder who needs a maintainable product foundation, not a one-off demo.
## Required capabilities
- Implement the core workflow described in ARCHITECTURE.md
## Explicit non-goals for v1
- SMS, WhatsApp and phone-call alerts. One webhook into a chat app covers the solo case; a provider bill covers the rest.
- Status pages, teams and on-call rotation. That is the paid product.
- Cron-expression parsing and insights. Period plus grace is enough and it is the part you can get right.
- their status pages and team features
- alert routing (SMS, PagerDuty)
- cron expression insights
## Success criteria
- A clean clone reaches a monitored job using only the README
- Every transition alerts exactly once in a soak test of one flapping and one stable check
- One restore drill performed and dated in OPERATIONS.md
- The monitor runs on a different provider than the jobs it watches
===== BRIEF.md =====
# Build brief · Cronitor
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a cron-job monitor like Cronitor or Healthchecks. Build it in phases,
in the order below. Do not write the whole service in one pass. Finish a phase,
run its "Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- Node 22 with `node:http` and `node:sqlite`, or Python 3.12 with stdlib
`http.server` and `sqlite3`. Pick one, no web framework.
- One process: HTTP server and scheduler loop in the same process.
- SQLite file at a path from `.env`. No ORM.
### Data model (create this before Phase 1)
- `checks`: id (uuid), name, period_seconds, grace_seconds, status
('new' | 'up' | 'late' | 'down' | 'paused'), last_ping_at, last_started_at,
last_duration_ms, created_at
- `pings`: id, check_id, received_at, kind ('start' | 'success' | 'fail' | 'log'),
exit_code, body (capped, see Phase 1), user_agent, remote_ip
- `alerts`: id, check_id, from_status, to_status, sent_at, delivered (bool), error
Index `pings(check_id, received_at)`. Every timestamp is UTC epoch milliseconds ·
never a local-time string, or the late/down maths silently breaks across a DST
boundary.
### Phase 1 · Ping ingestion
Build: the ping endpoints, matching the Healthchecks scheme so existing crontab
snippets port over unchanged.
- `GET|POST|HEAD /ping/:uuid` · success
- `/ping/:uuid/start` · job began, records last_started_at for duration
- `/ping/:uuid/fail` · explicit failure
- `/ping/:uuid/:exit_code` · 0 is success, 1-255 is failure
- `/ping/:uuid/log` · records a line without changing status
Accept HEAD, GET and POST. Store at most the first 100 kB of a POST body and
truncate silently beyond that. Always answer `200` with the body `OK`. An unknown
uuid answers `404` with `not found`. A ping must never fail because the database
is busy · use WAL mode and a short busy timeout.
Done when: `curl -fsS localhost:PORT/ping/<uuid>` prints `OK` and inserts one row;
posting a 2 MB body stores exactly 100 kB and still returns 200; and a ping to a
random uuid returns 404 without creating anything.
Do not build yet: status transitions, alerts, any UI.
### Phase 2 · Status state machine
Build: the scheduler loop, running every 30 seconds, that recomputes status.
- A check is `up` while `now <= last_ping_at + period`.
- It becomes `late` when `now > last_ping_at + period`.
- It becomes `down` when `now > last_ping_at + period + grace`.
- An explicit fail ping sets `down` immediately, whatever the timing.
- A success ping sets `up` immediately.
- `new` checks (never pinged) do not alert · they wait for a first ping.
- `paused` checks are skipped entirely.
Every transition writes one `alerts` row. Compute duration from a `/start`
followed by a success as `last_duration_ms`.
Done when: a check with period 60s and grace 30s reads `up` right after a ping,
`late` at 61s, and `down` at 91s, with exactly one alerts row per transition and
none for a repeated poll in the same state.
Do not build yet: sending anything.
### Phase 3 · Alerting
Build: a webhook sender (Discord, Slack or Telegram URL in `.env`) that drains
undelivered `alerts` rows. One message per state change · never per poll. Message
carries the check name, the new status, how late it is in human units ("14 min
late"), and the last failure body when there is one. Send a recovery message on
the return to `up`. Retry a failed delivery three times with exponential backoff,
then mark the row with its error and move on; a dead webhook must never stall
the loop or lose a later alert.
Done when: taking a check down produces exactly one message, leaving it down
produces no further messages, and bringing it back produces exactly one recovery
message. Pointing the webhook at a URL that 500s three times leaves a row with an
error and the loop still running.
### Phase 4 · Admin dashboard
Build: `/admin` behind basic auth from `.env`, with CRUD for checks (name, period,
grace, pause) and, per check, its ping URL with a copy button, a crontab example
line, a green/amber/red status dot, a relative last-ping time ("7 min ago"), and a
24-hour ping histogram drawn as inline SVG. No chart library, no client framework.
Done when: a check can be created, edited, paused and deleted from the browser;
the page renders correctly with zero checks; and the histogram matches the row
count in the database for the last 24 hours.
### Phase 5 · Hardening and deploy
Build: per-IP rate limiting on the ping route (generous · a legitimate job may
ping every minute), a `/healthz` endpoint, a pings retention job that deletes rows
older than a configurable number of days, a systemd unit, and the README.
Done when: the service survives a restart with state intact, retention actually
deletes, and the README takes a reader from clone to a monitored cron job.
### Out of scope (and why)
- Public status pages, teams, and on-call rotation. That is the paid product.
- SMS and PagerDuty routing · you would be paying a provider anyway, and one
webhook into a chat app covers the solo case.
- Cron-expression parsing and "insights". Period plus grace is enough, and it is
the part you can get right.
### README must contain
- The crontab one-liner: `*/5 * * * * /path/job.sh && curl -fsS <ping-url>`
- The wrapper form that reports failures too, using `/start` and `/fail`.
- A warning that a monitor on the same host as the jobs dies with the host · say
plainly that this is why hosted monitoring exists.
===== ARCHITECTURE.md =====
# Architecture · Cronitor
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Runtime | Node 22, node:http and node:sqlite | no framework and no ORM: a ping is one INSERT and the loop is a setInterval |
| Database | SQLite in WAL mode | one file, survives restarts, fast enough for millions of pings |
| Alerts | One chat webhook | the channel you already watch, no SMS provider bill |
| Hosting | A VPS that is not the box running your jobs | a monitor on the same host reports nothing when that host dies |
## Modules
Each module has one owner concern and a documented way to replace it.
| Module | Owns | How to replace it |
| --- | --- | --- |
| Ingest | the /ping routes and the pings table | Any HTTP listener writing the same rows; the scheme is Healthchecks-compatible so clients never change |
| Evaluator | the state machine and the alerts table | Pure function over rows; testable with a fake clock, replaceable without touching ingest |
| Notifier | formatting and delivering alerts with retries | One function per ALERT_FORMAT; add email or SMS as another adapter |
| Admin | basic-auth dashboard and forms | Any UI over the same tables; the ping URLs are the contract |
## Configuration
Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists.
- `PORT` · required · Any free port. Caddy proxies to it on the server.
- `DATABASE_PATH` · required · Where the SQLite file lives. Create the data/ folder; back this file up.
- `ALERT_WEBHOOK_URL` · required, secret · The chat webhook from the prerequisites.
- `ALERT_FORMAT` · required · discord, slack or telegram. Decides the JSON shape of the message.
- `ADMIN_USER` · required · Any username for the basic-auth admin pages.
- `ADMIN_PASS` · required, secret · Generate one: openssl rand -base64 24. Never reuse a real password.
- `SITE_URL` · required · Public base URL, used to print the ping URLs in the dashboard.
- `RETENTION_DAYS` · optional · How long to keep individual pings. Alerts and checks are kept forever.
- `CHECK_INTERVAL_SECONDS` · optional · How often the loop re-evaluates every check. 30 is plenty.
## Production baseline
- Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives.
- Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised.
- Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts.
- Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists.
- Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path.
## Decision records
For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
===== AGENTS.md =====
# Agent instructions · Cronitor product build
- Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22, node:http and node:sqlite, SQLite in WAL mode, One chat webhook, A VPS that is not the box running your jobs.
- Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit.
- Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present.
- Never invent cryptography or silently weaken a requirement to make a check pass.
- Put every external service behind an interface with a deterministic fake for tests.
- Add migrations and rollback or recovery notes for every persistent data change.
- Log useful operational context without credentials, tokens, passwords or personal data.
- Update documentation and run every check before completing a milestone.
## Known traps
- Store times as UTC epoch milliseconds, never local-time strings. The late maths breaks across a DST change otherwise.
- Do not validate the body. Jobs post arbitrary output; you store it and show it.
- Test the state machine with a fake clock before touching the webhook. Waiting real minutes to see a transition is how this phase eats an evening.
- Put the monitor on a different machine than the jobs. On the same host it dies with the host, which is precisely when it was supposed to speak.
===== MILESTONES.md =====
# Delivery milestones · Cronitor
Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer.
## M1 · Ping ingestion
Accept pings on URLs that match the Healthchecks scheme, store them, and never fail a ping because the database is busy.
### Steps
1. Create the project, the database file and the two tables
checks (id uuid, name, period_seconds, grace_seconds, status, last_ping_at, last_started_at, last_duration_ms) and pings (id, check_id, received_at, kind, exit_code, body). All timestamps as UTC epoch milliseconds. Open SQLite with WAL mode and a busy timeout.
Files: `server.mjs`, `db.mjs`, `.env`
```sh
mkdir cron-monitor && cd cron-monitor && git init && npm init -y && npm pkg set type=module
mkdir data
cp .env.example .env
```
2. Route /ping/:uuid and its variants
GET, POST and HEAD on /ping/:uuid is a success. /ping/:uuid/start records a start, /ping/:uuid/fail a failure, /ping/:uuid/log a log line, and /ping/:uuid/<0-255> treats 0 as success and anything else as failure.
3. Store at most 100 kB of a POST body and always answer OK
Read the body up to 100 kB and stop reading. Answer 200 with the plain text OK. An unknown uuid answers 404 with not found and inserts nothing.
4. Add a create-check script for testing
A tiny CLI that inserts a check row with a name, period and grace and prints its ping URL, so you can test before the dashboard exists.
```sh
node scripts/add-check.mjs "nightly backup" 86400 1800
```
### Done when
- [ ] curl -fsS http://localhost:3000/ping/<uuid> prints OK and one row appears in pings
- [ ] Posting a 2 MB body stores exactly 100 kB and still returns 200
- [ ] A ping to a random uuid returns 404 and creates nothing
- [ ] /ping/<uuid>/7 stores kind fail with exit_code 7
### Watch out
- Store times as UTC epoch milliseconds, never local-time strings. The late maths breaks across a DST change otherwise.
- Do not validate the body. Jobs post arbitrary output; you store it and show it.
## M2 · Status state machine
A loop that turns pings into up, late and down, alerts exactly once per transition, and never alerts for a check that has not pinged yet.
### Steps
1. Write the evaluation function
For each check: up while now is within period of last_ping_at; late once past period; down once past period plus grace. A fail ping sets down at once; any success sets up at once. Status new (never pinged) and paused are skipped.
2. Record every transition in an alerts table
alerts (id, check_id, from_status, to_status, sent_at, delivered, error). Write a row when status changes and only then.
3. Compute run duration from /start to success
When a success follows a start, set last_duration_ms. Show it later so a job that suddenly takes 40 minutes is visible.
4. Run the loop on CHECK_INTERVAL_SECONDS in the same process
setInterval, wrapped in try/catch so one bad row cannot stop the loop. Log one line per transition.
### Done when
- [ ] A check with period 60 and grace 30 reads up right after a ping, late at 61 seconds and down at 91
- [ ] Exactly one alerts row per transition and none for a repeated loop in the same state
- [ ] A fail ping flips a check to down immediately
- [ ] A new check with no pings never gets an alerts row
### Watch out
- Test the state machine with a fake clock before touching the webhook. Waiting real minutes to see a transition is how this phase eats an evening.
## M3 · Alerting
One chat message when a job goes down, one when it recovers, retries that never stall the loop.
### Steps
1. Write a sender for your ALERT_FORMAT
Discord wants {content}, Slack wants {text}, Telegram wants chat_id and text on the bot API. Include the check name, the new status, how late in human units (14 min late) and the last failure body if any.
2. Drain undelivered alerts rows after each loop
Send, mark delivered on 2xx. On failure retry three times with backoff (2 s, 10 s, 60 s), then record the error on the row and move on.
3. Send a recovery message on the return to up
It carries how long the outage lasted, from the down alert's sent_at.
### Done when
- [ ] Taking a check down produces exactly one message in the channel
- [ ] Leaving it down produces no further messages
- [ ] Recovery produces exactly one message with a duration
- [ ] Pointing ALERT_WEBHOOK_URL at a URL that returns 500 leaves an alerts row with an error and the loop still running
## M4 · Admin dashboard
Create and manage checks in the browser, and see every job's state at a glance.
### Steps
1. Add basic auth from ADMIN_USER and ADMIN_PASS
Compare with a constant-time function. Everything under /admin requires it; /ping never does.
2. Build the check list
One row per check: a green, amber or red dot, name, relative last ping (7 min ago), period and grace, and the ping URL with a copy button plus a ready-to-paste crontab example line.
3. Add create, edit, pause and delete forms
Plain HTML forms posting to /admin routes. No JavaScript required for any of them.
4. Draw a 24-hour ping histogram per check as inline SVG
One bar per hour from a GROUP BY on received_at. No chart library.
### Done when
- [ ] A check can be created, edited, paused and deleted from the browser
- [ ] The page renders correctly with zero checks
- [ ] The histogram bars match a count query for the last 24 hours
- [ ] The ping URL copied from the dashboard works in curl
## M5 · Hardening and deploy
Live on your VPS behind HTTPS, surviving restarts, with old pings pruned.
### Steps
1. Rate limit /ping per IP, generously
A legitimate job may ping every minute; allow 120 per minute per IP in memory and answer 429 beyond that.
2. Add /healthz and a nightly retention job
/healthz answers 200 with a quick database read. Retention deletes pings older than RETENTION_DAYS once a day; alerts and checks are never pruned.
3. Install on the VPS with systemd and Caddy
Unit with Restart=on-failure, EnvironmentFile=.env, an unprivileged user. Caddyfile: your domain with reverse_proxy localhost:PORT.
Files: `deploy/monitor.service`, `Caddyfile`
```sh
sudo cp deploy/monitor.service /etc/systemd/system/ && sudo systemctl enable --now monitor
sudo systemctl status monitor
```
4. Add the ping to one real crontab and write the README
README: the one-liner (*/5 * * * * /path/job.sh && curl -fsS <url>), the wrapper form that reports failures with /start and /fail, and the same-host warning.
Files: `README.md`
```sh
crontab -e
# 0 3 * * * /home/you/backup.sh && curl -fsS https://ping.yourdomain.com/ping/<uuid>
```
### Done when
- [ ] The service comes back with state intact after sudo reboot
- [ ] Retention actually deletes pings older than the window
- [ ] A real cron job on another machine shows up as up on the dashboard
- [ ] The README takes a reader from clone to a monitored job
### Watch out
- Put the monitor on a different machine than the jobs. On the same host it dies with the host, which is precisely when it was supposed to speak.
## M6 · Operate it like a product (production only)
Only for the product-builder path: know when the monitor itself is down, never lose the database, and keep the server patched.
### Steps
1. Add a /healthz endpoint and an external uptime check against it
Answer 200 with the build id and a quick database read. Point a free uptime monitor (or your own, from the Healthchecks entry on this site) at it so an outage is noticed before a user notices.
2. Write structured request logs and rotate them
One JSON line per request: method, path, status, duration, no raw IPs. Rotate weekly with logrotate, keep eight.
3. Back the SQLite file up off the machine nightly and test a restore
SQLite's .backup command makes a consistent copy while the app runs. Copy it to object storage or a second machine; then, once, restore it into a fresh checkout and confirm the app reads it.
```sh
sqlite3 data/app.db ".backup '/tmp/app-$(date +%F).db'"
rclone copy /tmp/app-$(date +%F).db remote:backups/
```
4. Lock the box down
Firewall allowing only 22, 80 and 443; unattended security updates on; the app running as an unprivileged user under systemd with Restart=on-failure.
### Done when
- [ ] Stopping the service triggers an uptime alert within a few minutes
- [ ] A restore from last night's backup contains yesterday's data
- [ ] A port scan from another machine shows only 22, 80 and 443
===== OPERATIONS.md =====
# Operations · Cronitor
## Backup
SQLite .backup nightly to object storage, thirty days kept. The checks table is the valuable part; pings are replaceable.
## Restore
Copy the backup to DATABASE_PATH, start the service, confirm the dashboard lists every check and pings resume.
Do a restore drill before the first real user, and write the date here when it passes.
## Monitoring
An external uptime check on /healthz from a different provider than the VPS. Watch the alerts table for rows with an error: that is the webhook breaking.
## Incident checklist
If the monitor is down, jobs are unwatched but unaffected; restore and re-check the last few hours by hand. If the webhook leaks, rotate it in the chat tool and update .env.
1. Contain the issue without destroying evidence or user data.
2. Record the timeline and affected scope.
3. Rotate exposed secrets and revoke compromised sessions or credentials.
4. Restore from a verified backup when needed.
5. Document the root cause, the remediation and the regression test.
## Release gate
- [ ] A clean clone reaches a monitored job using only the README
- [ ] Every transition alerts exactly once in a soak test of one flapping and one stable check
- [ ] One restore drill performed and dated in OPERATIONS.md
- [ ] The monitor runs on a different provider than the jobs it watches
## Launch constraint
Do not market omitted Cronitor capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. Any free port. Caddy proxies to it on the server.
PORT=3000
# Required. Where the SQLite file lives. Create the data/ folder; back this file up.
DATABASE_PATH=./data/monitor.db
# Required · secret. The chat webhook from the prerequisites.
ALERT_WEBHOOK_URL=https://discord.com/api/webhooks/...
# Required. discord, slack or telegram. Decides the JSON shape of the message.
ALERT_FORMAT=discord
# Required. Any username for the basic-auth admin pages.
ADMIN_USER=admin
# Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password.
ADMIN_PASS=change-me-to-a-long-random-string
# Required. Public base URL, used to print the ping URLs in the dashboard.
SITE_URL=https://ping.yourdomain.com
# Optional. How long to keep individual pings. Alerts and checks are kept forever.
RETENTION_DAYS=30
# Optional. How often the loop re-evaluates every check. 30 is plenty.
CHECK_INTERVAL_SECONDS=30
# Cronitor · indie build
A cron-job monitor you run yourself: every scheduled job gets a URL it pings when it finishes, the monitor flips a job to late and then down when the pings stop, one chat message goes out per state change, and a dashboard shows every job with its last ping and a 24-hour histogram. When every item is ticked you have Healthchecks-style monitoring for a few dollars of hosting.
Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts.
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Runtime | Node 22, node:http and node:sqlite | no framework and no ORM: a ping is one INSERT and the loop is a setInterval |
| Database | SQLite in WAL mode | one file, survives restarts, fast enough for millions of pings |
| Alerts | One chat webhook | the channel you already watch, no SMS provider bill |
| Hosting | A VPS that is not the box running your jobs | a monitor on the same host reports nothing when that host dies |
## Before you start
Have every one of these ready. The plan assumes them from step one.
- [ ] **Node.js 22 or newer** · free
- Why: Everything in this build runs on it: the server, the scripts, the tests.
- Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards.
- Verify: node --version prints v22 or higher
- [ ] **A terminal and a code editor** · free
- Why: Every step below is a command you type or a file you edit.
- Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal.
- Verify: You can open a folder and run a command in its terminal
- [ ] **Git** · free
- Why: History for your code, and the way most hosts deploy.
- Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists.
- Verify: git --version prints a version
- [ ] **A chat webhook URL (Discord, Slack or Telegram)** · free
- Why: Alerts go to a chat channel you already watch. A webhook URL is the only credential this needs.
- Get it: Discord: Server settings > Integrations > Webhooks > New Webhook, copy the URL. Slack: create an app at api.slack.com/apps, enable Incoming Webhooks, add to a channel, copy the URL. Telegram: create a bot with @BotFather and use the bot token plus your chat id.
- Verify: curl -X POST -H 'Content-Type: application/json' -d '{"content":"test"}' <url> posts a message (Discord form; Slack uses a text field)
- [ ] **The list of jobs you want watched** · free
- Why: Each job needs a name, how often it runs, and how late is too late. Deciding this up front is what makes Phase 2 testable.
- Get it: Run crontab -l on each machine and write down every job: name, schedule (every 5 minutes, hourly, nightly at 03:00), and a grace period (how long past due before you want to be told).
- [ ] **curl on the machines running the jobs** · free
- Why: The whole integration is appending && curl -fsS <url> to a crontab line.
- Get it: Already present on nearly every Linux and macOS system.
- Verify: curl --version prints a version
- [ ] **A small always-on server (VPS)** (optional) · about $5 a month
- Why: This needs one process running all the time with a public address. The monitor must live somewhere other than the machines it watches.
- Get it: Hetzner Cloud (from about 4 EUR), DigitalOcean or Fly.io. Ubuntu 24.04, the smallest size. You need SSH access and a public IP. Only needed for the deploy phase; develop locally first.
- [ ] **A domain or subdomain** (optional) · roughly $10 a year, or free on an existing domain
- Why: A stable address for ping URLs, so a server move does not mean editing every crontab.
- Get it: Register at Cloudflare Registrar, Porkbun or Namecheap, or use a subdomain of one you already own. You add one DNS record in the deploy phase.
- [ ] **Caddy on the server** (optional) · free
- Why: Automatic HTTPS in front of the Node process. Without TLS the browser features this relies on (and your visitors' trust) do not work.
- Get it: On the VPS: follow the install steps at caddyserver.com/docs/install for Ubuntu. One Caddyfile with your domain and a reverse_proxy line is the whole config.
- Verify: caddy version prints a version on the server
## Quick start
```sh
mkdir cron-monitor && cd cron-monitor && git init && npm init -y && npm pkg set type=module
mkdir data
cp .env.example .env
```
Then copy `.env.example` to `.env` and fill in the values it documents.
## Honest limits
This build deliberately does not replace:
- SMS, WhatsApp and phone-call alerts. One webhook into a chat app covers the solo case; a provider bill covers the rest.
- Status pages, teams and on-call rotation. That is the paid product.
- Cron-expression parsing and insights. Period plus grace is enough and it is the part you can get right.
- their status pages and team features
- alert routing (SMS, PagerDuty)
- cron expression insights
If one of those is essential to you, that is the reason to keep paying for Cronitor, and the README should say so rather than pretend.# Build brief · Cronitor
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a cron-job monitor like Cronitor or Healthchecks. Build it in phases,
in the order below. Do not write the whole service in one pass. Finish a phase,
run its "Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- Node 22 with `node:http` and `node:sqlite`, or Python 3.12 with stdlib
`http.server` and `sqlite3`. Pick one, no web framework.
- One process: HTTP server and scheduler loop in the same process.
- SQLite file at a path from `.env`. No ORM.
### Data model (create this before Phase 1)
- `checks`: id (uuid), name, period_seconds, grace_seconds, status
('new' | 'up' | 'late' | 'down' | 'paused'), last_ping_at, last_started_at,
last_duration_ms, created_at
- `pings`: id, check_id, received_at, kind ('start' | 'success' | 'fail' | 'log'),
exit_code, body (capped, see Phase 1), user_agent, remote_ip
- `alerts`: id, check_id, from_status, to_status, sent_at, delivered (bool), error
Index `pings(check_id, received_at)`. Every timestamp is UTC epoch milliseconds ·
never a local-time string, or the late/down maths silently breaks across a DST
boundary.
### Phase 1 · Ping ingestion
Build: the ping endpoints, matching the Healthchecks scheme so existing crontab
snippets port over unchanged.
- `GET|POST|HEAD /ping/:uuid` · success
- `/ping/:uuid/start` · job began, records last_started_at for duration
- `/ping/:uuid/fail` · explicit failure
- `/ping/:uuid/:exit_code` · 0 is success, 1-255 is failure
- `/ping/:uuid/log` · records a line without changing status
Accept HEAD, GET and POST. Store at most the first 100 kB of a POST body and
truncate silently beyond that. Always answer `200` with the body `OK`. An unknown
uuid answers `404` with `not found`. A ping must never fail because the database
is busy · use WAL mode and a short busy timeout.
Done when: `curl -fsS localhost:PORT/ping/<uuid>` prints `OK` and inserts one row;
posting a 2 MB body stores exactly 100 kB and still returns 200; and a ping to a
random uuid returns 404 without creating anything.
Do not build yet: status transitions, alerts, any UI.
### Phase 2 · Status state machine
Build: the scheduler loop, running every 30 seconds, that recomputes status.
- A check is `up` while `now <= last_ping_at + period`.
- It becomes `late` when `now > last_ping_at + period`.
- It becomes `down` when `now > last_ping_at + period + grace`.
- An explicit fail ping sets `down` immediately, whatever the timing.
- A success ping sets `up` immediately.
- `new` checks (never pinged) do not alert · they wait for a first ping.
- `paused` checks are skipped entirely.
Every transition writes one `alerts` row. Compute duration from a `/start`
followed by a success as `last_duration_ms`.
Done when: a check with period 60s and grace 30s reads `up` right after a ping,
`late` at 61s, and `down` at 91s, with exactly one alerts row per transition and
none for a repeated poll in the same state.
Do not build yet: sending anything.
### Phase 3 · Alerting
Build: a webhook sender (Discord, Slack or Telegram URL in `.env`) that drains
undelivered `alerts` rows. One message per state change · never per poll. Message
carries the check name, the new status, how late it is in human units ("14 min
late"), and the last failure body when there is one. Send a recovery message on
the return to `up`. Retry a failed delivery three times with exponential backoff,
then mark the row with its error and move on; a dead webhook must never stall
the loop or lose a later alert.
Done when: taking a check down produces exactly one message, leaving it down
produces no further messages, and bringing it back produces exactly one recovery
message. Pointing the webhook at a URL that 500s three times leaves a row with an
error and the loop still running.
### Phase 4 · Admin dashboard
Build: `/admin` behind basic auth from `.env`, with CRUD for checks (name, period,
grace, pause) and, per check, its ping URL with a copy button, a crontab example
line, a green/amber/red status dot, a relative last-ping time ("7 min ago"), and a
24-hour ping histogram drawn as inline SVG. No chart library, no client framework.
Done when: a check can be created, edited, paused and deleted from the browser;
the page renders correctly with zero checks; and the histogram matches the row
count in the database for the last 24 hours.
### Phase 5 · Hardening and deploy
Build: per-IP rate limiting on the ping route (generous · a legitimate job may
ping every minute), a `/healthz` endpoint, a pings retention job that deletes rows
older than a configurable number of days, a systemd unit, and the README.
Done when: the service survives a restart with state intact, retention actually
deletes, and the README takes a reader from clone to a monitored cron job.
### Out of scope (and why)
- Public status pages, teams, and on-call rotation. That is the paid product.
- SMS and PagerDuty routing · you would be paying a provider anyway, and one
webhook into a chat app covers the solo case.
- Cron-expression parsing and "insights". Period plus grace is enough, and it is
the part you can get right.
### README must contain
- The crontab one-liner: `*/5 * * * * /path/job.sh && curl -fsS <ping-url>`
- The wrapper form that reports failures too, using `/start` and `/fail`.
- A warning that a monitor on the same host as the jobs dies with the host · say
plainly that this is why hosted monitoring exists.# Agent instructions · Cronitor indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22, node:http and node:sqlite, SQLite in WAL mode, One chat webhook, A VPS that is not the box running your jobs. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Store times as UTC epoch milliseconds, never local-time strings. The late maths breaks across a DST change otherwise. - Do not validate the body. Jobs post arbitrary output; you store it and show it. - Test the state machine with a fake clock before touching the webhook. Waiting real minutes to see a transition is how this phase eats an evening. - Put the monitor on a different machine than the jobs. On the same host it dies with the host, which is precisely when it was supposed to speak.
# Build plan · Cronitor
A cron-job monitor you run yourself: every scheduled job gets a URL it pings when it finishes, the monitor flips a job to late and then down when the pings stop, one chat message goes out per state change, and a dashboard shows every job with its last ping and a 24-hour histogram. When every item is ticked you have Healthchecks-style monitoring for a few dollars of hosting.
Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note.
## Phase 1 · Ping ingestion
Accept pings on URLs that match the Healthchecks scheme, store them, and never fail a ping because the database is busy.
### Steps
1. Create the project, the database file and the two tables
checks (id uuid, name, period_seconds, grace_seconds, status, last_ping_at, last_started_at, last_duration_ms) and pings (id, check_id, received_at, kind, exit_code, body). All timestamps as UTC epoch milliseconds. Open SQLite with WAL mode and a busy timeout.
Files: `server.mjs`, `db.mjs`, `.env`
```sh
mkdir cron-monitor && cd cron-monitor && git init && npm init -y && npm pkg set type=module
mkdir data
cp .env.example .env
```
2. Route /ping/:uuid and its variants
GET, POST and HEAD on /ping/:uuid is a success. /ping/:uuid/start records a start, /ping/:uuid/fail a failure, /ping/:uuid/log a log line, and /ping/:uuid/<0-255> treats 0 as success and anything else as failure.
3. Store at most 100 kB of a POST body and always answer OK
Read the body up to 100 kB and stop reading. Answer 200 with the plain text OK. An unknown uuid answers 404 with not found and inserts nothing.
4. Add a create-check script for testing
A tiny CLI that inserts a check row with a name, period and grace and prints its ping URL, so you can test before the dashboard exists.
```sh
node scripts/add-check.mjs "nightly backup" 86400 1800
```
### Done when
- [ ] curl -fsS http://localhost:3000/ping/<uuid> prints OK and one row appears in pings
- [ ] Posting a 2 MB body stores exactly 100 kB and still returns 200
- [ ] A ping to a random uuid returns 404 and creates nothing
- [ ] /ping/<uuid>/7 stores kind fail with exit_code 7
### Watch out
- Store times as UTC epoch milliseconds, never local-time strings. The late maths breaks across a DST change otherwise.
- Do not validate the body. Jobs post arbitrary output; you store it and show it.
## Phase 2 · Status state machine
A loop that turns pings into up, late and down, alerts exactly once per transition, and never alerts for a check that has not pinged yet.
### Steps
1. Write the evaluation function
For each check: up while now is within period of last_ping_at; late once past period; down once past period plus grace. A fail ping sets down at once; any success sets up at once. Status new (never pinged) and paused are skipped.
2. Record every transition in an alerts table
alerts (id, check_id, from_status, to_status, sent_at, delivered, error). Write a row when status changes and only then.
3. Compute run duration from /start to success
When a success follows a start, set last_duration_ms. Show it later so a job that suddenly takes 40 minutes is visible.
4. Run the loop on CHECK_INTERVAL_SECONDS in the same process
setInterval, wrapped in try/catch so one bad row cannot stop the loop. Log one line per transition.
### Done when
- [ ] A check with period 60 and grace 30 reads up right after a ping, late at 61 seconds and down at 91
- [ ] Exactly one alerts row per transition and none for a repeated loop in the same state
- [ ] A fail ping flips a check to down immediately
- [ ] A new check with no pings never gets an alerts row
### Watch out
- Test the state machine with a fake clock before touching the webhook. Waiting real minutes to see a transition is how this phase eats an evening.
## Phase 3 · Alerting
One chat message when a job goes down, one when it recovers, retries that never stall the loop.
### Steps
1. Write a sender for your ALERT_FORMAT
Discord wants {content}, Slack wants {text}, Telegram wants chat_id and text on the bot API. Include the check name, the new status, how late in human units (14 min late) and the last failure body if any.
2. Drain undelivered alerts rows after each loop
Send, mark delivered on 2xx. On failure retry three times with backoff (2 s, 10 s, 60 s), then record the error on the row and move on.
3. Send a recovery message on the return to up
It carries how long the outage lasted, from the down alert's sent_at.
### Done when
- [ ] Taking a check down produces exactly one message in the channel
- [ ] Leaving it down produces no further messages
- [ ] Recovery produces exactly one message with a duration
- [ ] Pointing ALERT_WEBHOOK_URL at a URL that returns 500 leaves an alerts row with an error and the loop still running
## Phase 4 · Admin dashboard
Create and manage checks in the browser, and see every job's state at a glance.
### Steps
1. Add basic auth from ADMIN_USER and ADMIN_PASS
Compare with a constant-time function. Everything under /admin requires it; /ping never does.
2. Build the check list
One row per check: a green, amber or red dot, name, relative last ping (7 min ago), period and grace, and the ping URL with a copy button plus a ready-to-paste crontab example line.
3. Add create, edit, pause and delete forms
Plain HTML forms posting to /admin routes. No JavaScript required for any of them.
4. Draw a 24-hour ping histogram per check as inline SVG
One bar per hour from a GROUP BY on received_at. No chart library.
### Done when
- [ ] A check can be created, edited, paused and deleted from the browser
- [ ] The page renders correctly with zero checks
- [ ] The histogram bars match a count query for the last 24 hours
- [ ] The ping URL copied from the dashboard works in curl
## Phase 5 · Hardening and deploy
Live on your VPS behind HTTPS, surviving restarts, with old pings pruned.
### Steps
1. Rate limit /ping per IP, generously
A legitimate job may ping every minute; allow 120 per minute per IP in memory and answer 429 beyond that.
2. Add /healthz and a nightly retention job
/healthz answers 200 with a quick database read. Retention deletes pings older than RETENTION_DAYS once a day; alerts and checks are never pruned.
3. Install on the VPS with systemd and Caddy
Unit with Restart=on-failure, EnvironmentFile=.env, an unprivileged user. Caddyfile: your domain with reverse_proxy localhost:PORT.
Files: `deploy/monitor.service`, `Caddyfile`
```sh
sudo cp deploy/monitor.service /etc/systemd/system/ && sudo systemctl enable --now monitor
sudo systemctl status monitor
```
4. Add the ping to one real crontab and write the README
README: the one-liner (*/5 * * * * /path/job.sh && curl -fsS <url>), the wrapper form that reports failures with /start and /fail, and the same-host warning.
Files: `README.md`
```sh
crontab -e
# 0 3 * * * /home/you/backup.sh && curl -fsS https://ping.yourdomain.com/ping/<uuid>
```
### Done when
- [ ] The service comes back with state intact after sudo reboot
- [ ] Retention actually deletes pings older than the window
- [ ] A real cron job on another machine shows up as up on the dashboard
- [ ] The README takes a reader from clone to a monitored job
### Watch out
- Put the monitor on a different machine than the jobs. On the same host it dies with the host, which is precisely when it was supposed to speak.
## Not in this build
- SMS, WhatsApp and phone-call alerts. One webhook into a chat app covers the solo case; a provider bill covers the rest.
- Status pages, teams and on-call rotation. That is the paid product.
- Cron-expression parsing and insights. Period plus grace is enough and it is the part you can get right.
## After v1, if you want it
- A second alert channel (email over SMTP) behind the same Notifier interface
- A public read-only status page rendered from the checks table# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Any free port. Caddy proxies to it on the server. PORT=3000 # Required. Where the SQLite file lives. Create the data/ folder; back this file up. DATABASE_PATH=./data/monitor.db # Required · secret. The chat webhook from the prerequisites. ALERT_WEBHOOK_URL=https://discord.com/api/webhooks/... # Required. discord, slack or telegram. Decides the JSON shape of the message. ALERT_FORMAT=discord # Required. Any username for the basic-auth admin pages. ADMIN_USER=admin # Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password. ADMIN_PASS=change-me-to-a-long-random-string # Required. Public base URL, used to print the ping URLs in the dashboard. SITE_URL=https://ping.yourdomain.com # Optional. How long to keep individual pings. Alerts and checks are kept forever. RETENTION_DAYS=30 # Optional. How often the loop re-evaluates every check. 30 is plenty. CHECK_INTERVAL_SECONDS=30
# Cronitor · product brief ## Problem Heartbeat endpoint plus dead-man's-switch alerts. A hundred lines of code wearing a SaaS badge. ## Product outcome A monitoring service others could rely on: every job watched from a box that is not theirs, alerts that fire once and recover, and the monitor itself monitored and backed up. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Implement the core workflow described in ARCHITECTURE.md ## Explicit non-goals for v1 - SMS, WhatsApp and phone-call alerts. One webhook into a chat app covers the solo case; a provider bill covers the rest. - Status pages, teams and on-call rotation. That is the paid product. - Cron-expression parsing and insights. Period plus grace is enough and it is the part you can get right. - their status pages and team features - alert routing (SMS, PagerDuty) - cron expression insights ## Success criteria - A clean clone reaches a monitored job using only the README - Every transition alerts exactly once in a soak test of one flapping and one stable check - One restore drill performed and dated in OPERATIONS.md - The monitor runs on a different provider than the jobs it watches
# Build brief · Cronitor
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a cron-job monitor like Cronitor or Healthchecks. Build it in phases,
in the order below. Do not write the whole service in one pass. Finish a phase,
run its "Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- Node 22 with `node:http` and `node:sqlite`, or Python 3.12 with stdlib
`http.server` and `sqlite3`. Pick one, no web framework.
- One process: HTTP server and scheduler loop in the same process.
- SQLite file at a path from `.env`. No ORM.
### Data model (create this before Phase 1)
- `checks`: id (uuid), name, period_seconds, grace_seconds, status
('new' | 'up' | 'late' | 'down' | 'paused'), last_ping_at, last_started_at,
last_duration_ms, created_at
- `pings`: id, check_id, received_at, kind ('start' | 'success' | 'fail' | 'log'),
exit_code, body (capped, see Phase 1), user_agent, remote_ip
- `alerts`: id, check_id, from_status, to_status, sent_at, delivered (bool), error
Index `pings(check_id, received_at)`. Every timestamp is UTC epoch milliseconds ·
never a local-time string, or the late/down maths silently breaks across a DST
boundary.
### Phase 1 · Ping ingestion
Build: the ping endpoints, matching the Healthchecks scheme so existing crontab
snippets port over unchanged.
- `GET|POST|HEAD /ping/:uuid` · success
- `/ping/:uuid/start` · job began, records last_started_at for duration
- `/ping/:uuid/fail` · explicit failure
- `/ping/:uuid/:exit_code` · 0 is success, 1-255 is failure
- `/ping/:uuid/log` · records a line without changing status
Accept HEAD, GET and POST. Store at most the first 100 kB of a POST body and
truncate silently beyond that. Always answer `200` with the body `OK`. An unknown
uuid answers `404` with `not found`. A ping must never fail because the database
is busy · use WAL mode and a short busy timeout.
Done when: `curl -fsS localhost:PORT/ping/<uuid>` prints `OK` and inserts one row;
posting a 2 MB body stores exactly 100 kB and still returns 200; and a ping to a
random uuid returns 404 without creating anything.
Do not build yet: status transitions, alerts, any UI.
### Phase 2 · Status state machine
Build: the scheduler loop, running every 30 seconds, that recomputes status.
- A check is `up` while `now <= last_ping_at + period`.
- It becomes `late` when `now > last_ping_at + period`.
- It becomes `down` when `now > last_ping_at + period + grace`.
- An explicit fail ping sets `down` immediately, whatever the timing.
- A success ping sets `up` immediately.
- `new` checks (never pinged) do not alert · they wait for a first ping.
- `paused` checks are skipped entirely.
Every transition writes one `alerts` row. Compute duration from a `/start`
followed by a success as `last_duration_ms`.
Done when: a check with period 60s and grace 30s reads `up` right after a ping,
`late` at 61s, and `down` at 91s, with exactly one alerts row per transition and
none for a repeated poll in the same state.
Do not build yet: sending anything.
### Phase 3 · Alerting
Build: a webhook sender (Discord, Slack or Telegram URL in `.env`) that drains
undelivered `alerts` rows. One message per state change · never per poll. Message
carries the check name, the new status, how late it is in human units ("14 min
late"), and the last failure body when there is one. Send a recovery message on
the return to `up`. Retry a failed delivery three times with exponential backoff,
then mark the row with its error and move on; a dead webhook must never stall
the loop or lose a later alert.
Done when: taking a check down produces exactly one message, leaving it down
produces no further messages, and bringing it back produces exactly one recovery
message. Pointing the webhook at a URL that 500s three times leaves a row with an
error and the loop still running.
### Phase 4 · Admin dashboard
Build: `/admin` behind basic auth from `.env`, with CRUD for checks (name, period,
grace, pause) and, per check, its ping URL with a copy button, a crontab example
line, a green/amber/red status dot, a relative last-ping time ("7 min ago"), and a
24-hour ping histogram drawn as inline SVG. No chart library, no client framework.
Done when: a check can be created, edited, paused and deleted from the browser;
the page renders correctly with zero checks; and the histogram matches the row
count in the database for the last 24 hours.
### Phase 5 · Hardening and deploy
Build: per-IP rate limiting on the ping route (generous · a legitimate job may
ping every minute), a `/healthz` endpoint, a pings retention job that deletes rows
older than a configurable number of days, a systemd unit, and the README.
Done when: the service survives a restart with state intact, retention actually
deletes, and the README takes a reader from clone to a monitored cron job.
### Out of scope (and why)
- Public status pages, teams, and on-call rotation. That is the paid product.
- SMS and PagerDuty routing · you would be paying a provider anyway, and one
webhook into a chat app covers the solo case.
- Cron-expression parsing and "insights". Period plus grace is enough, and it is
the part you can get right.
### README must contain
- The crontab one-liner: `*/5 * * * * /path/job.sh && curl -fsS <ping-url>`
- The wrapper form that reports failures too, using `/start` and `/fail`.
- A warning that a monitor on the same host as the jobs dies with the host · say
plainly that this is why hosted monitoring exists.# Architecture · Cronitor ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22, node:http and node:sqlite | no framework and no ORM: a ping is one INSERT and the loop is a setInterval | | Database | SQLite in WAL mode | one file, survives restarts, fast enough for millions of pings | | Alerts | One chat webhook | the channel you already watch, no SMS provider bill | | Hosting | A VPS that is not the box running your jobs | a monitor on the same host reports nothing when that host dies | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Ingest | the /ping routes and the pings table | Any HTTP listener writing the same rows; the scheme is Healthchecks-compatible so clients never change | | Evaluator | the state machine and the alerts table | Pure function over rows; testable with a fake clock, replaceable without touching ingest | | Notifier | formatting and delivering alerts with retries | One function per ALERT_FORMAT; add email or SMS as another adapter | | Admin | basic-auth dashboard and forms | Any UI over the same tables; the ping URLs are the contract | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `PORT` · required · Any free port. Caddy proxies to it on the server. - `DATABASE_PATH` · required · Where the SQLite file lives. Create the data/ folder; back this file up. - `ALERT_WEBHOOK_URL` · required, secret · The chat webhook from the prerequisites. - `ALERT_FORMAT` · required · discord, slack or telegram. Decides the JSON shape of the message. - `ADMIN_USER` · required · Any username for the basic-auth admin pages. - `ADMIN_PASS` · required, secret · Generate one: openssl rand -base64 24. Never reuse a real password. - `SITE_URL` · required · Public base URL, used to print the ping URLs in the dashboard. - `RETENTION_DAYS` · optional · How long to keep individual pings. Alerts and checks are kept forever. - `CHECK_INTERVAL_SECONDS` · optional · How often the loop re-evaluates every check. 30 is plenty. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Cronitor product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22, node:http and node:sqlite, SQLite in WAL mode, One chat webhook, A VPS that is not the box running your jobs. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Store times as UTC epoch milliseconds, never local-time strings. The late maths breaks across a DST change otherwise. - Do not validate the body. Jobs post arbitrary output; you store it and show it. - Test the state machine with a fake clock before touching the webhook. Waiting real minutes to see a transition is how this phase eats an evening. - Put the monitor on a different machine than the jobs. On the same host it dies with the host, which is precisely when it was supposed to speak.
# Delivery milestones · Cronitor
Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer.
## M1 · Ping ingestion
Accept pings on URLs that match the Healthchecks scheme, store them, and never fail a ping because the database is busy.
### Steps
1. Create the project, the database file and the two tables
checks (id uuid, name, period_seconds, grace_seconds, status, last_ping_at, last_started_at, last_duration_ms) and pings (id, check_id, received_at, kind, exit_code, body). All timestamps as UTC epoch milliseconds. Open SQLite with WAL mode and a busy timeout.
Files: `server.mjs`, `db.mjs`, `.env`
```sh
mkdir cron-monitor && cd cron-monitor && git init && npm init -y && npm pkg set type=module
mkdir data
cp .env.example .env
```
2. Route /ping/:uuid and its variants
GET, POST and HEAD on /ping/:uuid is a success. /ping/:uuid/start records a start, /ping/:uuid/fail a failure, /ping/:uuid/log a log line, and /ping/:uuid/<0-255> treats 0 as success and anything else as failure.
3. Store at most 100 kB of a POST body and always answer OK
Read the body up to 100 kB and stop reading. Answer 200 with the plain text OK. An unknown uuid answers 404 with not found and inserts nothing.
4. Add a create-check script for testing
A tiny CLI that inserts a check row with a name, period and grace and prints its ping URL, so you can test before the dashboard exists.
```sh
node scripts/add-check.mjs "nightly backup" 86400 1800
```
### Done when
- [ ] curl -fsS http://localhost:3000/ping/<uuid> prints OK and one row appears in pings
- [ ] Posting a 2 MB body stores exactly 100 kB and still returns 200
- [ ] A ping to a random uuid returns 404 and creates nothing
- [ ] /ping/<uuid>/7 stores kind fail with exit_code 7
### Watch out
- Store times as UTC epoch milliseconds, never local-time strings. The late maths breaks across a DST change otherwise.
- Do not validate the body. Jobs post arbitrary output; you store it and show it.
## M2 · Status state machine
A loop that turns pings into up, late and down, alerts exactly once per transition, and never alerts for a check that has not pinged yet.
### Steps
1. Write the evaluation function
For each check: up while now is within period of last_ping_at; late once past period; down once past period plus grace. A fail ping sets down at once; any success sets up at once. Status new (never pinged) and paused are skipped.
2. Record every transition in an alerts table
alerts (id, check_id, from_status, to_status, sent_at, delivered, error). Write a row when status changes and only then.
3. Compute run duration from /start to success
When a success follows a start, set last_duration_ms. Show it later so a job that suddenly takes 40 minutes is visible.
4. Run the loop on CHECK_INTERVAL_SECONDS in the same process
setInterval, wrapped in try/catch so one bad row cannot stop the loop. Log one line per transition.
### Done when
- [ ] A check with period 60 and grace 30 reads up right after a ping, late at 61 seconds and down at 91
- [ ] Exactly one alerts row per transition and none for a repeated loop in the same state
- [ ] A fail ping flips a check to down immediately
- [ ] A new check with no pings never gets an alerts row
### Watch out
- Test the state machine with a fake clock before touching the webhook. Waiting real minutes to see a transition is how this phase eats an evening.
## M3 · Alerting
One chat message when a job goes down, one when it recovers, retries that never stall the loop.
### Steps
1. Write a sender for your ALERT_FORMAT
Discord wants {content}, Slack wants {text}, Telegram wants chat_id and text on the bot API. Include the check name, the new status, how late in human units (14 min late) and the last failure body if any.
2. Drain undelivered alerts rows after each loop
Send, mark delivered on 2xx. On failure retry three times with backoff (2 s, 10 s, 60 s), then record the error on the row and move on.
3. Send a recovery message on the return to up
It carries how long the outage lasted, from the down alert's sent_at.
### Done when
- [ ] Taking a check down produces exactly one message in the channel
- [ ] Leaving it down produces no further messages
- [ ] Recovery produces exactly one message with a duration
- [ ] Pointing ALERT_WEBHOOK_URL at a URL that returns 500 leaves an alerts row with an error and the loop still running
## M4 · Admin dashboard
Create and manage checks in the browser, and see every job's state at a glance.
### Steps
1. Add basic auth from ADMIN_USER and ADMIN_PASS
Compare with a constant-time function. Everything under /admin requires it; /ping never does.
2. Build the check list
One row per check: a green, amber or red dot, name, relative last ping (7 min ago), period and grace, and the ping URL with a copy button plus a ready-to-paste crontab example line.
3. Add create, edit, pause and delete forms
Plain HTML forms posting to /admin routes. No JavaScript required for any of them.
4. Draw a 24-hour ping histogram per check as inline SVG
One bar per hour from a GROUP BY on received_at. No chart library.
### Done when
- [ ] A check can be created, edited, paused and deleted from the browser
- [ ] The page renders correctly with zero checks
- [ ] The histogram bars match a count query for the last 24 hours
- [ ] The ping URL copied from the dashboard works in curl
## M5 · Hardening and deploy
Live on your VPS behind HTTPS, surviving restarts, with old pings pruned.
### Steps
1. Rate limit /ping per IP, generously
A legitimate job may ping every minute; allow 120 per minute per IP in memory and answer 429 beyond that.
2. Add /healthz and a nightly retention job
/healthz answers 200 with a quick database read. Retention deletes pings older than RETENTION_DAYS once a day; alerts and checks are never pruned.
3. Install on the VPS with systemd and Caddy
Unit with Restart=on-failure, EnvironmentFile=.env, an unprivileged user. Caddyfile: your domain with reverse_proxy localhost:PORT.
Files: `deploy/monitor.service`, `Caddyfile`
```sh
sudo cp deploy/monitor.service /etc/systemd/system/ && sudo systemctl enable --now monitor
sudo systemctl status monitor
```
4. Add the ping to one real crontab and write the README
README: the one-liner (*/5 * * * * /path/job.sh && curl -fsS <url>), the wrapper form that reports failures with /start and /fail, and the same-host warning.
Files: `README.md`
```sh
crontab -e
# 0 3 * * * /home/you/backup.sh && curl -fsS https://ping.yourdomain.com/ping/<uuid>
```
### Done when
- [ ] The service comes back with state intact after sudo reboot
- [ ] Retention actually deletes pings older than the window
- [ ] A real cron job on another machine shows up as up on the dashboard
- [ ] The README takes a reader from clone to a monitored job
### Watch out
- Put the monitor on a different machine than the jobs. On the same host it dies with the host, which is precisely when it was supposed to speak.
## M6 · Operate it like a product (production only)
Only for the product-builder path: know when the monitor itself is down, never lose the database, and keep the server patched.
### Steps
1. Add a /healthz endpoint and an external uptime check against it
Answer 200 with the build id and a quick database read. Point a free uptime monitor (or your own, from the Healthchecks entry on this site) at it so an outage is noticed before a user notices.
2. Write structured request logs and rotate them
One JSON line per request: method, path, status, duration, no raw IPs. Rotate weekly with logrotate, keep eight.
3. Back the SQLite file up off the machine nightly and test a restore
SQLite's .backup command makes a consistent copy while the app runs. Copy it to object storage or a second machine; then, once, restore it into a fresh checkout and confirm the app reads it.
```sh
sqlite3 data/app.db ".backup '/tmp/app-$(date +%F).db'"
rclone copy /tmp/app-$(date +%F).db remote:backups/
```
4. Lock the box down
Firewall allowing only 22, 80 and 443; unattended security updates on; the app running as an unprivileged user under systemd with Restart=on-failure.
### Done when
- [ ] Stopping the service triggers an uptime alert within a few minutes
- [ ] A restore from last night's backup contains yesterday's data
- [ ] A port scan from another machine shows only 22, 80 and 443# Operations · Cronitor ## Backup SQLite .backup nightly to object storage, thirty days kept. The checks table is the valuable part; pings are replaceable. ## Restore Copy the backup to DATABASE_PATH, start the service, confirm the dashboard lists every check and pings resume. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring An external uptime check on /healthz from a different provider than the VPS. Watch the alerts table for rows with an error: that is the webhook breaking. ## Incident checklist If the monitor is down, jobs are unwatched but unaffected; restore and re-check the last few hours by hand. If the webhook leaks, rotate it in the chat tool and update .env. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A clean clone reaches a monitored job using only the README - [ ] Every transition alerts exactly once in a soak test of one flapping and one stable check - [ ] One restore drill performed and dated in OPERATIONS.md - [ ] The monitor runs on a different provider than the jobs it watches ## Launch constraint Do not market omitted Cronitor capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Any free port. Caddy proxies to it on the server. PORT=3000 # Required. Where the SQLite file lives. Create the data/ folder; back this file up. DATABASE_PATH=./data/monitor.db # Required · secret. The chat webhook from the prerequisites. ALERT_WEBHOOK_URL=https://discord.com/api/webhooks/... # Required. discord, slack or telegram. Decides the JSON shape of the message. ALERT_FORMAT=discord # Required. Any username for the basic-auth admin pages. ADMIN_USER=admin # Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password. ADMIN_PASS=change-me-to-a-long-random-string # Required. Public base URL, used to print the ping URLs in the dashboard. SITE_URL=https://ping.yourdomain.com # Optional. How long to keep individual pings. Alerts and checks are kept forever. RETENTION_DAYS=30 # Optional. How often the loop re-evaluates every check. 30 is plenty. CHECK_INTERVAL_SECONDS=30
$ choose a build depth, inspect the files, then open the complete pack in your agent
xtheir status pages and team features
xalert routing (SMS, PagerDuty)
xcron expression insights
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
Vibecode Cronitor
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Cronitor replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Cronitor cost?
Cronitor costs about $10/month (paid plan, checked 2026-07-29), which is $120 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing Cronitor?
Honestly: their status pages and team features; alert routing (SMS, PagerDuty); cron expression insights. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Cronitor?
Yes: Uptime Kuma (Push monitors and ordinary uptime checks under one very cheerful roof.) Healthchecks (Cron phones home; this notices when it stops.) The prompt is for when you want it exactly your way.