Build Cronitor
YESreplaces $10/mosaves $120/yrback to the verdict
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.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
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. open ↗
Verify
node --version prints v22 or higher - installfree
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. open ↗
Verify
You can open a folder and run a command in its terminal - installfree
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. open ↗
Verify
git --version prints a version - API keyfree
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. open ↗
Verify
curl -X POST -H 'Content-Type: application/json' -d '{"content":"test"}' <url> posts a message (Discord form; Slack uses a text field) - have readyfree
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).
- installfree
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 - accountabout $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. open ↗
- accountroughly $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. open ↗
- installfree
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. open ↗
Verify
caddy version prints a version on the server
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- `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.Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
PORT | required | 3000 | Any free port. Caddy proxies to it on the server. |
DATABASE_PATH | required | ./data/monitor.db | Where the SQLite file lives. Create the data/ folder; back this file up. |
ALERT_WEBHOOK_URLsecret | required | https://discord.com/api/webhooks/... | The chat webhook from the prerequisites. |
ALERT_FORMAT | required | discord | discord, slack or telegram. Decides the JSON shape of the message. |
ADMIN_USER | required | admin | Any username for the basic-auth admin pages. |
ADMIN_PASSsecret | required | change-me-to-a-long-random-string | Generate one: openssl rand -base64 24. Never reuse a real password. |
SITE_URL | required | https://ping.yourdomain.com | Public base URL, used to print the ping URLs in the dashboard. |
RETENTION_DAYS | optional | 30 | How long to keep individual pings. Alerts and checks are kept forever. |
CHECK_INTERVAL_SECONDS | optional | 30 | How often the loop re-evaluates every check. 30 is plenty. |
The build, in order
Ping ingestion
Accept pings on URLs that match the Healthchecks scheme, store them, and never fail a ping because the database is busy.
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.mjsdb.mjs.envterminalmkdir cron-monitor && cd cron-monitor && git init && npm init -y && npm pkg set type=module mkdir data cp .env.example .env
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.
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.
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.
terminalnode scripts/add-check.mjs "nightly backup" 86400 1800
done when · tick each as it passeswatch 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.
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.
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.
alerts (id, check_id, from_status, to_status, sent_at, delivered, error). Write a row when status changes and only then.
When a success follows a start, set last_duration_ms. Show it later so a job that suddenly takes 40 minutes is visible.
setInterval, wrapped in try/catch so one bad row cannot stop the loop. Log one line per transition.
done when · tick each as it passeswatch 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.
Alerting
One chat message when a job goes down, one when it recovers, retries that never stall the loop.
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.
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.
It carries how long the outage lasted, from the down alert's sent_at.
done when · tick each as it passesAdmin dashboard
Create and manage checks in the browser, and see every job's state at a glance.
Compare with a constant-time function. Everything under /admin requires it; /ping never does.
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.
Plain HTML forms posting to /admin routes. No JavaScript required for any of them.
One bar per hour from a GROUP BY on received_at. No chart library.
done when · tick each as it passesHardening and deploy
Live on your VPS behind HTTPS, surviving restarts, with old pings pruned.
A legitimate job may ping every minute; allow 120 per minute per IP in memory and answer 429 beyond that.
/healthz answers 200 with a quick database read. Retention deletes pings older than RETENTION_DAYS once a day; alerts and checks are never pruned.
Unit with Restart=on-failure, EnvironmentFile=.env, an unprivileged user. Caddyfile: your domain with reverse_proxy localhost:PORT.
Files
deploy/monitor.serviceCaddyfileterminalsudo cp deploy/monitor.service /etc/systemd/system/ && sudo systemctl enable --now monitor sudo systemctl status monitor
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.mdterminalcrontab -e # 0 3 * * * /home/you/backup.sh && curl -fsS https://ping.yourdomain.com/ping/<uuid>
done when · tick each as it passeswatch 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.
Operate it like a productproduct builder
Only for the product-builder path: know when the monitor itself is down, never lose the database, and keep the server patched.
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.
One JSON line per request: method, path, status, duration, no raw IPs. Rotate weekly with logrotate, keep eight.
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.
terminalsqlite3 data/app.db ".backup '/tmp/app-$(date +%F).db'" rclone copy /tmp/app-$(date +%F).db remote:backups/
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 · tick each as it passes
That is the whole plan for Cronitor. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- 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
- A second alert channel (email over SMTP) behind the same Notifier interface
- A public read-only status page rendered from the checks table
Need the files? The project pack on the verdict page hands your agent the whole brief · more cron monitoring.