Build Cronitor

YESreplaces $10/mosaves $120/yrback to the verdict

0%0 of 28 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

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 sittingthe files for this build are in the project pack

RuntimeNode 22, node:http and node:sqliteDatabaseSQLite in WAL modeAlertsOne chat webhookHostingA VPS that is not the box running your jobs

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. 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

  2. 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

  3. 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

  4. 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)

  5. 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).

  6. 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

  7. 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. open ↗

  8. 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. open ↗

  9. 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.

VariableNeededExampleWhere the value comes from
PORTrequired3000Any free port. Caddy proxies to it on the server.
DATABASE_PATHrequired./data/monitor.dbWhere the SQLite file lives. Create the data/ folder; back this file up.
ALERT_WEBHOOK_URLsecretrequiredhttps://discord.com/api/webhooks/...The chat webhook from the prerequisites.
ALERT_FORMATrequireddiscorddiscord, slack or telegram. Decides the JSON shape of the message.
ADMIN_USERrequiredadminAny username for the basic-auth admin pages.
ADMIN_PASSsecretrequiredchange-me-to-a-long-random-stringGenerate one: openssl rand -base64 24. Never reuse a real password.
SITE_URLrequiredhttps://ping.yourdomain.comPublic base URL, used to print the ping URLs in the dashboard.
RETENTION_DAYSoptional30How long to keep individual pings. Alerts and checks are kept forever.
CHECK_INTERVAL_SECONDSoptional30How often the loop re-evaluates every check. 30 is plenty.

The build, in order

  1. Ping ingestion

    Accept pings on URLs that match the Healthchecks scheme, store them, and never fail a ping because the database is busy.

    1. 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.env

      terminal
      mkdir cron-monitor && cd cron-monitor && git init && npm init -y && npm pkg set type=module
      mkdir data
      cp .env.example .env
    2. 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. 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. 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.

      terminal
      node scripts/add-check.mjs "nightly backup" 86400 1800
    done when · tick each as it passes
    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.
  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.

    1. 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. alerts (id, check_id, from_status, to_status, sent_at, delivered, error). Write a row when status changes and only then.

    3. When a success follows a start, set last_duration_ms. Show it later so a job that suddenly takes 40 minutes is visible.

    4. setInterval, wrapped in try/catch so one bad row cannot stop the loop. Log one line per transition.

    done when · tick each as it passes
    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.
  3. Alerting

    One chat message when a job goes down, one when it recovers, retries that never stall the loop.

    1. 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. 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. It carries how long the outage lasted, from the down alert's sent_at.

    done when · tick each as it passes
  4. Admin dashboard

    Create and manage checks in the browser, and see every job's state at a glance.

    1. Compare with a constant-time function. Everything under /admin requires it; /ping never does.

    2. 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. Plain HTML forms posting to /admin routes. No JavaScript required for any of them.

    4. One bar per hour from a GROUP BY on received_at. No chart library.

    done when · tick each as it passes
  5. Hardening and deploy

    Live on your VPS behind HTTPS, surviving restarts, with old pings pruned.

    1. A legitimate job may ping every minute; allow 120 per minute per IP in memory and answer 429 beyond that.

    2. /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. Unit with Restart=on-failure, EnvironmentFile=.env, an unprivileged user. Caddyfile: your domain with reverse_proxy localhost:PORT.

      Files deploy/monitor.serviceCaddyfile

      terminal
      sudo cp deploy/monitor.service /etc/systemd/system/ && sudo systemctl enable --now monitor
      sudo systemctl status monitor
    4. 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

      terminal
      crontab -e
      # 0 3 * * * /home/you/backup.sh && curl -fsS https://ping.yourdomain.com/ping/<uuid>
    done when · tick each as it passes
    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.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more cron monitoring.