Vibecode Healthchecks.io
track this build5 phases, 11 steps, beginner friendly0%The server is open source and the protocol is a GET to a URL. A single-process monitor with a period, a grace time and one webhook covers the personal case in a sitting; the hosted product sells the alerting fan-out and not being on the same box as your jobs.
You are building a lean indie version of Healthchecks.io.
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 =====
# Healthchecks.io · indie build
A heartbeat monitor for cron jobs: each job pings a URL when it finishes, the monitor calls it late and then down when the pings stop, one chat message per state change, and a dashboard with a 24-hour histogram. Healthchecks.io itself is open source, so self-hosting it is the fuller answer; this is the 300-line version you understand completely.
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 | a ping is one INSERT; the loop is a timer |
| Database | SQLite in WAL mode | one file that never fails a ping because it is busy |
| Alerts | One chat webhook | the channel you already watch |
| Hosting | A VPS separate from the jobs | a monitor on the same host reports nothing when it 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)
- [ ] **Your list of cron jobs with expected periods** · free
- Why: Each check needs a period and a grace time; deciding them makes Phase 2 testable.
- Get it: crontab -l on every machine; write down name, schedule and how late is too late.
- [ ] **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 running the jobs.
- 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: Stable ping URLs that survive a server move.
- 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 heartbeats && cd heartbeats && git init && npm init -y && npm pkg set type=module
mkdir -p 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.
- Teams, projects and the integrations catalogue.
- Being off your infrastructure by itself: put it on a different box.
- SMS, WhatsApp and phone-call alerts
- a monitor that lives off your infrastructure
- the integrations catalogue (PagerDuty, Opsgenie, Slack app)
- team accounts and project sharing
If one of those is essential to you, that is the reason to keep paying for Healthchecks.io, and the README should say so rather than pretend.
===== BRIEF.md =====
# Build brief · Healthchecks.io
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 heartbeat monitor like Healthchecks.io. Build it in phases, in the order below. Do not write the whole thing 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. No web framework.
- One process for the HTTP listener and the checker loop. SQLite in WAL mode at a path from .env.
### 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
- pings: id, check_id, received_at, kind ('start' | 'success' | 'fail' | 'log'), exit_code, body
- alerts: id, check_id, from_status, to_status, sent_at, delivered, error
All timestamps are UTC epoch milliseconds · a local-time string breaks the late maths across a DST change.
### Phase 1 · Ping ingestion
Build: GET, POST and HEAD on /ping/:uuid, plus /ping/:uuid/start, /ping/:uuid/fail, /ping/:uuid/log and /ping/:uuid/:exit_code where 0 is success and 1-255 is failure. Store at most the first 100 kB of a POST body. Always answer 200 with the body OK; an unknown uuid answers 404.
Done when: a curl to a real uuid returns OK and inserts one row, a 2 MB body stores exactly 100 kB, and a random uuid returns 404 without creating anything.
Do not build yet: status changes, alerts, UI.
### Phase 2 · Status state machine
Build: a loop every 30 seconds. up while now is within period of the last ping; late once past period; down once past period plus grace. An explicit fail ping goes down immediately; any success goes up immediately. new checks never alert; paused checks are skipped. Every transition writes one alerts row. A /start followed by a success records the duration.
Done when: a 60 s period with 30 s grace reads up after a ping, late at 61 s and down at 91 s, 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 (Slack, Discord or Telegram URL in .env) that drains undelivered alerts rows. One message per state change, never per poll, carrying the check name, the new status, how late in human units, and the last failure body. A recovery message on the return to up. Three retries with backoff, then record the error and move on.
Done when: taking a check down produces exactly one message, leaving it down produces none, recovery produces exactly one, and a webhook URL that 500s leaves a row with an error and the loop still running.
### Phase 4 · Dashboard
Build: /admin behind basic auth from .env with CRUD for checks, and per check its ping URL with a copy button, a crontab example line, a status dot, a relative last-ping time and a 24-hour ping histogram as inline SVG. No chart library.
Done when: a check can be created, edited, paused and deleted in the browser, the page renders with zero checks, and the histogram matches a count query for the last 24 hours.
### Phase 5 · Hardening and deploy
Build: generous per-IP rate limiting on /ping, a /healthz endpoint, a retention job for old pings, a systemd unit and the README.
Done when: the service restarts with state intact, retention deletes, and the README takes a reader from clone to a monitored job.
### Out of scope (and why)
- SMS, WhatsApp and phone calls. You would be paying a provider anyway; one chat webhook covers the solo case.
- Teams, projects and the integrations catalogue.
- Running somewhere else. This is the real limit: a monitor on the same host as your jobs reports nothing when that host dies. Put it on a different box or a different provider, and say so in the README.
### README must contain
- The crontab one-liner ending in && curl -fsS <ping-url>, and the wrapper form that reports failures with /start and /fail.
- The same-host warning, stated once and plainly.
- A line noting Healthchecks.io is itself open source, and that self-hosting it is the fuller answer if you outgrow this.
===== AGENTS.md =====
# Agent instructions · Healthchecks.io 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 separate from the 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
- Never local-time strings for timestamps; the late maths breaks across DST.
===== BUILD_PLAN.md =====
# Build plan · Healthchecks.io
A heartbeat monitor for cron jobs: each job pings a URL when it finishes, the monitor calls it late and then down when the pings stop, one chat message per state change, and a dashboard with a 24-hour histogram. Healthchecks.io itself is open source, so self-hosting it is the fuller answer; this is the 300-line version you understand completely.
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 the Healthchecks scheme so existing crontab snippets port unchanged.
### Steps
1. Create the project and the tables
checks (id uuid, name, period_seconds, grace_seconds, status, last_ping_at, last_started_at, last_duration_ms), pings (id, check_id, received_at, kind, exit_code, body), alerts (id, check_id, from_status, to_status, sent_at, delivered, error). UTC epoch milliseconds throughout.
```sh
mkdir heartbeats && cd heartbeats && git init && npm init -y && npm pkg set type=module
mkdir -p data && cp .env.example .env
```
2. Route GET, POST and HEAD on /ping/:uuid plus /start, /fail, /log and /:exit_code
0 is success, 1-255 failure. Store at most 100 kB of body. Always answer 200 OK; unknown uuids 404.
3. Add a small CLI to create a check for testing
```sh
node scripts/add-check.mjs "nightly backup" 86400 1800
```
### Done when
- [ ] curl -fsS to a real uuid prints OK and inserts one row
- [ ] A 2 MB body stores exactly 100 kB and returns 200
- [ ] A random uuid returns 404 without creating anything
### Watch out
- Never local-time strings for timestamps; the late maths breaks across DST.
## Phase 2 · State machine
up, late, down, computed every 30 seconds, one alerts row per transition.
### Steps
1. Write the evaluation: up within period, late past period, down past period plus grace; fail pings flip down at once; new and paused never alert
2. Record transitions in alerts and durations from /start to success
### Done when
- [ ] Period 60 with grace 30 reads up, late at 61 s, down at 91 s
- [ ] Exactly one alerts row per transition
- [ ] A new check never alerts
## Phase 3 · Alerting
One message per state change with retries that never stall the loop.
### Steps
1. Write the sender for ALERT_FORMAT and drain undelivered alerts after each loop
2. Retry three times with backoff, then record the error on the row
### Done when
- [ ] Taking a check down produces exactly one message
- [ ] Recovery produces exactly one
- [ ] A webhook that 500s leaves an error on the row and the loop running
## Phase 4 · Dashboard
Manage checks in the browser and see every job at a glance.
### Steps
1. Basic auth on /admin with CRUD and the ping URL plus a crontab line per check
2. A status dot, relative last ping and a 24-hour histogram as inline SVG
### Done when
- [ ] Create, edit, pause and delete work in the browser
- [ ] Renders with zero checks
- [ ] Histogram matches a count query
## Phase 5 · Hardening and deploy
Rate limits, retention, HTTPS, a real crontab.
### Steps
1. Rate limit /ping generously per IP; add /healthz and nightly retention
2. systemd unit, Caddy, one real crontab line, the README
README: the one-liner with && curl -fsS, the /start and /fail wrapper, the same-host warning, and a line that Healthchecks.io is open source if you outgrow this.
Files: `README.md`, `deploy/heartbeats.service`, `Caddyfile`
```sh
# */5 * * * * /path/job.sh && curl -fsS https://hc.yourdomain.com/ping/<uuid>
```
### Done when
- [ ] State survives a reboot
- [ ] A real cron job on another machine shows up
- [ ] The README reaches a monitored job
## Not in this build
- SMS, WhatsApp and phone-call alerts.
- Teams, projects and the integrations catalogue.
- Being off your infrastructure by itself: put it on a different box.
## After v1, if you want it
- A second alert channel behind the Notifier interface
- A read-only status page 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.
PORT=3000
# Required. SQLite file.
DATABASE_PATH=./data/checks.db
# Required · secret. Chat webhook from the prerequisites.
ALERT_WEBHOOK_URL=https://hooks.slack.com/services/...
# Required. discord, slack or telegram.
ALERT_FORMAT=slack
# Required. Public base URL printed in ping URLs.
SITE_URL=https://hc.yourdomain.com
# Optional. How long pings are kept.
RETENTION_DAYS=30
# 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
You are building a lean indie version of Healthchecks.io.
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 =====
# Healthchecks.io · indie build
A heartbeat monitor for cron jobs: each job pings a URL when it finishes, the monitor calls it late and then down when the pings stop, one chat message per state change, and a dashboard with a 24-hour histogram. Healthchecks.io itself is open source, so self-hosting it is the fuller answer; this is the 300-line version you understand completely.
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 | a ping is one INSERT; the loop is a timer |
| Database | SQLite in WAL mode | one file that never fails a ping because it is busy |
| Alerts | One chat webhook | the channel you already watch |
| Hosting | A VPS separate from the jobs | a monitor on the same host reports nothing when it 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)
- [ ] **Your list of cron jobs with expected periods** · free
- Why: Each check needs a period and a grace time; deciding them makes Phase 2 testable.
- Get it: crontab -l on every machine; write down name, schedule and how late is too late.
- [ ] **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 running the jobs.
- 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: Stable ping URLs that survive a server move.
- 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 heartbeats && cd heartbeats && git init && npm init -y && npm pkg set type=module
mkdir -p 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.
- Teams, projects and the integrations catalogue.
- Being off your infrastructure by itself: put it on a different box.
- SMS, WhatsApp and phone-call alerts
- a monitor that lives off your infrastructure
- the integrations catalogue (PagerDuty, Opsgenie, Slack app)
- team accounts and project sharing
If one of those is essential to you, that is the reason to keep paying for Healthchecks.io, and the README should say so rather than pretend.
===== BRIEF.md =====
# Build brief · Healthchecks.io
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 heartbeat monitor like Healthchecks.io. Build it in phases, in the order below. Do not write the whole thing 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. No web framework.
- One process for the HTTP listener and the checker loop. SQLite in WAL mode at a path from .env.
### 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
- pings: id, check_id, received_at, kind ('start' | 'success' | 'fail' | 'log'), exit_code, body
- alerts: id, check_id, from_status, to_status, sent_at, delivered, error
All timestamps are UTC epoch milliseconds · a local-time string breaks the late maths across a DST change.
### Phase 1 · Ping ingestion
Build: GET, POST and HEAD on /ping/:uuid, plus /ping/:uuid/start, /ping/:uuid/fail, /ping/:uuid/log and /ping/:uuid/:exit_code where 0 is success and 1-255 is failure. Store at most the first 100 kB of a POST body. Always answer 200 with the body OK; an unknown uuid answers 404.
Done when: a curl to a real uuid returns OK and inserts one row, a 2 MB body stores exactly 100 kB, and a random uuid returns 404 without creating anything.
Do not build yet: status changes, alerts, UI.
### Phase 2 · Status state machine
Build: a loop every 30 seconds. up while now is within period of the last ping; late once past period; down once past period plus grace. An explicit fail ping goes down immediately; any success goes up immediately. new checks never alert; paused checks are skipped. Every transition writes one alerts row. A /start followed by a success records the duration.
Done when: a 60 s period with 30 s grace reads up after a ping, late at 61 s and down at 91 s, 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 (Slack, Discord or Telegram URL in .env) that drains undelivered alerts rows. One message per state change, never per poll, carrying the check name, the new status, how late in human units, and the last failure body. A recovery message on the return to up. Three retries with backoff, then record the error and move on.
Done when: taking a check down produces exactly one message, leaving it down produces none, recovery produces exactly one, and a webhook URL that 500s leaves a row with an error and the loop still running.
### Phase 4 · Dashboard
Build: /admin behind basic auth from .env with CRUD for checks, and per check its ping URL with a copy button, a crontab example line, a status dot, a relative last-ping time and a 24-hour ping histogram as inline SVG. No chart library.
Done when: a check can be created, edited, paused and deleted in the browser, the page renders with zero checks, and the histogram matches a count query for the last 24 hours.
### Phase 5 · Hardening and deploy
Build: generous per-IP rate limiting on /ping, a /healthz endpoint, a retention job for old pings, a systemd unit and the README.
Done when: the service restarts with state intact, retention deletes, and the README takes a reader from clone to a monitored job.
### Out of scope (and why)
- SMS, WhatsApp and phone calls. You would be paying a provider anyway; one chat webhook covers the solo case.
- Teams, projects and the integrations catalogue.
- Running somewhere else. This is the real limit: a monitor on the same host as your jobs reports nothing when that host dies. Put it on a different box or a different provider, and say so in the README.
### README must contain
- The crontab one-liner ending in && curl -fsS <ping-url>, and the wrapper form that reports failures with /start and /fail.
- The same-host warning, stated once and plainly.
- A line noting Healthchecks.io is itself open source, and that self-hosting it is the fuller answer if you outgrow this.
===== AGENTS.md =====
# Agent instructions · Healthchecks.io 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 separate from the 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
- Never local-time strings for timestamps; the late maths breaks across DST.
===== BUILD_PLAN.md =====
# Build plan · Healthchecks.io
A heartbeat monitor for cron jobs: each job pings a URL when it finishes, the monitor calls it late and then down when the pings stop, one chat message per state change, and a dashboard with a 24-hour histogram. Healthchecks.io itself is open source, so self-hosting it is the fuller answer; this is the 300-line version you understand completely.
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 the Healthchecks scheme so existing crontab snippets port unchanged.
### Steps
1. Create the project and the tables
checks (id uuid, name, period_seconds, grace_seconds, status, last_ping_at, last_started_at, last_duration_ms), pings (id, check_id, received_at, kind, exit_code, body), alerts (id, check_id, from_status, to_status, sent_at, delivered, error). UTC epoch milliseconds throughout.
```sh
mkdir heartbeats && cd heartbeats && git init && npm init -y && npm pkg set type=module
mkdir -p data && cp .env.example .env
```
2. Route GET, POST and HEAD on /ping/:uuid plus /start, /fail, /log and /:exit_code
0 is success, 1-255 failure. Store at most 100 kB of body. Always answer 200 OK; unknown uuids 404.
3. Add a small CLI to create a check for testing
```sh
node scripts/add-check.mjs "nightly backup" 86400 1800
```
### Done when
- [ ] curl -fsS to a real uuid prints OK and inserts one row
- [ ] A 2 MB body stores exactly 100 kB and returns 200
- [ ] A random uuid returns 404 without creating anything
### Watch out
- Never local-time strings for timestamps; the late maths breaks across DST.
## Phase 2 · State machine
up, late, down, computed every 30 seconds, one alerts row per transition.
### Steps
1. Write the evaluation: up within period, late past period, down past period plus grace; fail pings flip down at once; new and paused never alert
2. Record transitions in alerts and durations from /start to success
### Done when
- [ ] Period 60 with grace 30 reads up, late at 61 s, down at 91 s
- [ ] Exactly one alerts row per transition
- [ ] A new check never alerts
## Phase 3 · Alerting
One message per state change with retries that never stall the loop.
### Steps
1. Write the sender for ALERT_FORMAT and drain undelivered alerts after each loop
2. Retry three times with backoff, then record the error on the row
### Done when
- [ ] Taking a check down produces exactly one message
- [ ] Recovery produces exactly one
- [ ] A webhook that 500s leaves an error on the row and the loop running
## Phase 4 · Dashboard
Manage checks in the browser and see every job at a glance.
### Steps
1. Basic auth on /admin with CRUD and the ping URL plus a crontab line per check
2. A status dot, relative last ping and a 24-hour histogram as inline SVG
### Done when
- [ ] Create, edit, pause and delete work in the browser
- [ ] Renders with zero checks
- [ ] Histogram matches a count query
## Phase 5 · Hardening and deploy
Rate limits, retention, HTTPS, a real crontab.
### Steps
1. Rate limit /ping generously per IP; add /healthz and nightly retention
2. systemd unit, Caddy, one real crontab line, the README
README: the one-liner with && curl -fsS, the /start and /fail wrapper, the same-host warning, and a line that Healthchecks.io is open source if you outgrow this.
Files: `README.md`, `deploy/heartbeats.service`, `Caddyfile`
```sh
# */5 * * * * /path/job.sh && curl -fsS https://hc.yourdomain.com/ping/<uuid>
```
### Done when
- [ ] State survives a reboot
- [ ] A real cron job on another machine shows up
- [ ] The README reaches a monitored job
## Not in this build
- SMS, WhatsApp and phone-call alerts.
- Teams, projects and the integrations catalogue.
- Being off your infrastructure by itself: put it on a different box.
## After v1, if you want it
- A second alert channel behind the Notifier interface
- A read-only status page 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.
PORT=3000
# Required. SQLite file.
DATABASE_PATH=./data/checks.db
# Required · secret. Chat webhook from the prerequisites.
ALERT_WEBHOOK_URL=https://hooks.slack.com/services/...
# Required. discord, slack or telegram.
ALERT_FORMAT=slack
# Required. Public base URL printed in ping URLs.
SITE_URL=https://hc.yourdomain.com
# Optional. How long pings are kept.
RETENTION_DAYS=30
# 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
You are building a production product version of Healthchecks.io.
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 =====
# Healthchecks.io · product brief
## Problem
The server is open source and the protocol is a GET to a URL. A single-process monitor with a period, a grace time and one webhook covers the personal case in a sitting; the hosted product sells the alerting fan-out and not being on the same box as your jobs.
## Product outcome
Heartbeat monitoring others could rely on, watched from a box that is not theirs, itself monitored and backed up.
## Target user
A builder who needs a maintainable product foundation, not a one-off demo.
## Required capabilities
- an always-on host that is not the box running the jobs
- a chat webhook for alerts
## Explicit non-goals for v1
- SMS, WhatsApp and phone-call alerts.
- Teams, projects and the integrations catalogue.
- Being off your infrastructure by itself: put it on a different box.
- SMS, WhatsApp and phone-call alerts
- a monitor that lives off your infrastructure
- the integrations catalogue (PagerDuty, Opsgenie, Slack app)
- team accounts and project sharing
## Success criteria
- Runs on a different provider than the jobs
- Soak test: one flapping and one stable check alert correctly
- One restore drill performed
===== BRIEF.md =====
# Build brief · Healthchecks.io
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 heartbeat monitor like Healthchecks.io. Build it in phases, in the order below. Do not write the whole thing 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. No web framework.
- One process for the HTTP listener and the checker loop. SQLite in WAL mode at a path from .env.
### 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
- pings: id, check_id, received_at, kind ('start' | 'success' | 'fail' | 'log'), exit_code, body
- alerts: id, check_id, from_status, to_status, sent_at, delivered, error
All timestamps are UTC epoch milliseconds · a local-time string breaks the late maths across a DST change.
### Phase 1 · Ping ingestion
Build: GET, POST and HEAD on /ping/:uuid, plus /ping/:uuid/start, /ping/:uuid/fail, /ping/:uuid/log and /ping/:uuid/:exit_code where 0 is success and 1-255 is failure. Store at most the first 100 kB of a POST body. Always answer 200 with the body OK; an unknown uuid answers 404.
Done when: a curl to a real uuid returns OK and inserts one row, a 2 MB body stores exactly 100 kB, and a random uuid returns 404 without creating anything.
Do not build yet: status changes, alerts, UI.
### Phase 2 · Status state machine
Build: a loop every 30 seconds. up while now is within period of the last ping; late once past period; down once past period plus grace. An explicit fail ping goes down immediately; any success goes up immediately. new checks never alert; paused checks are skipped. Every transition writes one alerts row. A /start followed by a success records the duration.
Done when: a 60 s period with 30 s grace reads up after a ping, late at 61 s and down at 91 s, 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 (Slack, Discord or Telegram URL in .env) that drains undelivered alerts rows. One message per state change, never per poll, carrying the check name, the new status, how late in human units, and the last failure body. A recovery message on the return to up. Three retries with backoff, then record the error and move on.
Done when: taking a check down produces exactly one message, leaving it down produces none, recovery produces exactly one, and a webhook URL that 500s leaves a row with an error and the loop still running.
### Phase 4 · Dashboard
Build: /admin behind basic auth from .env with CRUD for checks, and per check its ping URL with a copy button, a crontab example line, a status dot, a relative last-ping time and a 24-hour ping histogram as inline SVG. No chart library.
Done when: a check can be created, edited, paused and deleted in the browser, the page renders with zero checks, and the histogram matches a count query for the last 24 hours.
### Phase 5 · Hardening and deploy
Build: generous per-IP rate limiting on /ping, a /healthz endpoint, a retention job for old pings, a systemd unit and the README.
Done when: the service restarts with state intact, retention deletes, and the README takes a reader from clone to a monitored job.
### Out of scope (and why)
- SMS, WhatsApp and phone calls. You would be paying a provider anyway; one chat webhook covers the solo case.
- Teams, projects and the integrations catalogue.
- Running somewhere else. This is the real limit: a monitor on the same host as your jobs reports nothing when that host dies. Put it on a different box or a different provider, and say so in the README.
### README must contain
- The crontab one-liner ending in && curl -fsS <ping-url>, and the wrapper form that reports failures with /start and /fail.
- The same-host warning, stated once and plainly.
- A line noting Healthchecks.io is itself open source, and that self-hosting it is the fuller answer if you outgrow this.
===== ARCHITECTURE.md =====
# Architecture · Healthchecks.io
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Runtime | Node 22, node:http and node:sqlite | a ping is one INSERT; the loop is a timer |
| Database | SQLite in WAL mode | one file that never fails a ping because it is busy |
| Alerts | One chat webhook | the channel you already watch |
| Hosting | A VPS separate from the jobs | a monitor on the same host reports nothing when it dies |
## Modules
Each module has one owner concern and a documented way to replace it.
| Module | Owns | How to replace it |
| --- | --- | --- |
| Ingest | /ping routes | Healthchecks-compatible, so clients never change |
| Evaluator | the state machine | Pure function, fake-clock testable |
| Notifier | formatting and retries | One function per format |
| Admin | dashboard and forms | Any UI over the tables |
## 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.
- `DATABASE_PATH` · required · SQLite file.
- `ALERT_WEBHOOK_URL` · required, secret · Chat webhook from the prerequisites.
- `ALERT_FORMAT` · required · discord, slack or telegram.
- `SITE_URL` · required · Public base URL printed in ping URLs.
- `RETENTION_DAYS` · optional · How long pings are kept.
- `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.
## 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 · Healthchecks.io 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 separate from the 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
- Never local-time strings for timestamps; the late maths breaks across DST.
===== MILESTONES.md =====
# Delivery milestones · Healthchecks.io
Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer.
## M1 · Ping ingestion
Accept pings on the Healthchecks scheme so existing crontab snippets port unchanged.
### Steps
1. Create the project and the tables
checks (id uuid, name, period_seconds, grace_seconds, status, last_ping_at, last_started_at, last_duration_ms), pings (id, check_id, received_at, kind, exit_code, body), alerts (id, check_id, from_status, to_status, sent_at, delivered, error). UTC epoch milliseconds throughout.
```sh
mkdir heartbeats && cd heartbeats && git init && npm init -y && npm pkg set type=module
mkdir -p data && cp .env.example .env
```
2. Route GET, POST and HEAD on /ping/:uuid plus /start, /fail, /log and /:exit_code
0 is success, 1-255 failure. Store at most 100 kB of body. Always answer 200 OK; unknown uuids 404.
3. Add a small CLI to create a check for testing
```sh
node scripts/add-check.mjs "nightly backup" 86400 1800
```
### Done when
- [ ] curl -fsS to a real uuid prints OK and inserts one row
- [ ] A 2 MB body stores exactly 100 kB and returns 200
- [ ] A random uuid returns 404 without creating anything
### Watch out
- Never local-time strings for timestamps; the late maths breaks across DST.
## M2 · State machine
up, late, down, computed every 30 seconds, one alerts row per transition.
### Steps
1. Write the evaluation: up within period, late past period, down past period plus grace; fail pings flip down at once; new and paused never alert
2. Record transitions in alerts and durations from /start to success
### Done when
- [ ] Period 60 with grace 30 reads up, late at 61 s, down at 91 s
- [ ] Exactly one alerts row per transition
- [ ] A new check never alerts
## M3 · Alerting
One message per state change with retries that never stall the loop.
### Steps
1. Write the sender for ALERT_FORMAT and drain undelivered alerts after each loop
2. Retry three times with backoff, then record the error on the row
### Done when
- [ ] Taking a check down produces exactly one message
- [ ] Recovery produces exactly one
- [ ] A webhook that 500s leaves an error on the row and the loop running
## M4 · Dashboard
Manage checks in the browser and see every job at a glance.
### Steps
1. Basic auth on /admin with CRUD and the ping URL plus a crontab line per check
2. A status dot, relative last ping and a 24-hour histogram as inline SVG
### Done when
- [ ] Create, edit, pause and delete work in the browser
- [ ] Renders with zero checks
- [ ] Histogram matches a count query
## M5 · Hardening and deploy
Rate limits, retention, HTTPS, a real crontab.
### Steps
1. Rate limit /ping generously per IP; add /healthz and nightly retention
2. systemd unit, Caddy, one real crontab line, the README
README: the one-liner with && curl -fsS, the /start and /fail wrapper, the same-host warning, and a line that Healthchecks.io is open source if you outgrow this.
Files: `README.md`, `deploy/heartbeats.service`, `Caddyfile`
```sh
# */5 * * * * /path/job.sh && curl -fsS https://hc.yourdomain.com/ping/<uuid>
```
### Done when
- [ ] State survives a reboot
- [ ] A real cron job on another machine shows up
- [ ] The README reaches a monitored job
## 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 · Healthchecks.io
## Backup
SQLite .backup nightly off the box.
## Restore
Copy back; the dashboard lists every check.
Do a restore drill before the first real user, and write the date here when it passes.
## Monitoring
An external check on /healthz from another provider.
## Incident checklist
If the monitor is down, jobs are unwatched not broken; restore and review the gap.
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
- [ ] Runs on a different provider than the jobs
- [ ] Soak test: one flapping and one stable check alert correctly
- [ ] One restore drill performed
## Launch constraint
Do not market omitted Healthchecks.io 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.
PORT=3000
# Required. SQLite file.
DATABASE_PATH=./data/checks.db
# Required · secret. Chat webhook from the prerequisites.
ALERT_WEBHOOK_URL=https://hooks.slack.com/services/...
# Required. discord, slack or telegram.
ALERT_FORMAT=slack
# Required. Public base URL printed in ping URLs.
SITE_URL=https://hc.yourdomain.com
# Optional. How long pings are kept.
RETENTION_DAYS=30
# 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
# Healthchecks.io · indie build
A heartbeat monitor for cron jobs: each job pings a URL when it finishes, the monitor calls it late and then down when the pings stop, one chat message per state change, and a dashboard with a 24-hour histogram. Healthchecks.io itself is open source, so self-hosting it is the fuller answer; this is the 300-line version you understand completely.
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 | a ping is one INSERT; the loop is a timer |
| Database | SQLite in WAL mode | one file that never fails a ping because it is busy |
| Alerts | One chat webhook | the channel you already watch |
| Hosting | A VPS separate from the jobs | a monitor on the same host reports nothing when it 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)
- [ ] **Your list of cron jobs with expected periods** · free
- Why: Each check needs a period and a grace time; deciding them makes Phase 2 testable.
- Get it: crontab -l on every machine; write down name, schedule and how late is too late.
- [ ] **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 running the jobs.
- 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: Stable ping URLs that survive a server move.
- 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 heartbeats && cd heartbeats && git init && npm init -y && npm pkg set type=module
mkdir -p 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.
- Teams, projects and the integrations catalogue.
- Being off your infrastructure by itself: put it on a different box.
- SMS, WhatsApp and phone-call alerts
- a monitor that lives off your infrastructure
- the integrations catalogue (PagerDuty, Opsgenie, Slack app)
- team accounts and project sharing
If one of those is essential to you, that is the reason to keep paying for Healthchecks.io, and the README should say so rather than pretend.# Build brief · Healthchecks.io
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 heartbeat monitor like Healthchecks.io. Build it in phases, in the order below. Do not write the whole thing 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. No web framework.
- One process for the HTTP listener and the checker loop. SQLite in WAL mode at a path from .env.
### 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
- pings: id, check_id, received_at, kind ('start' | 'success' | 'fail' | 'log'), exit_code, body
- alerts: id, check_id, from_status, to_status, sent_at, delivered, error
All timestamps are UTC epoch milliseconds · a local-time string breaks the late maths across a DST change.
### Phase 1 · Ping ingestion
Build: GET, POST and HEAD on /ping/:uuid, plus /ping/:uuid/start, /ping/:uuid/fail, /ping/:uuid/log and /ping/:uuid/:exit_code where 0 is success and 1-255 is failure. Store at most the first 100 kB of a POST body. Always answer 200 with the body OK; an unknown uuid answers 404.
Done when: a curl to a real uuid returns OK and inserts one row, a 2 MB body stores exactly 100 kB, and a random uuid returns 404 without creating anything.
Do not build yet: status changes, alerts, UI.
### Phase 2 · Status state machine
Build: a loop every 30 seconds. up while now is within period of the last ping; late once past period; down once past period plus grace. An explicit fail ping goes down immediately; any success goes up immediately. new checks never alert; paused checks are skipped. Every transition writes one alerts row. A /start followed by a success records the duration.
Done when: a 60 s period with 30 s grace reads up after a ping, late at 61 s and down at 91 s, 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 (Slack, Discord or Telegram URL in .env) that drains undelivered alerts rows. One message per state change, never per poll, carrying the check name, the new status, how late in human units, and the last failure body. A recovery message on the return to up. Three retries with backoff, then record the error and move on.
Done when: taking a check down produces exactly one message, leaving it down produces none, recovery produces exactly one, and a webhook URL that 500s leaves a row with an error and the loop still running.
### Phase 4 · Dashboard
Build: /admin behind basic auth from .env with CRUD for checks, and per check its ping URL with a copy button, a crontab example line, a status dot, a relative last-ping time and a 24-hour ping histogram as inline SVG. No chart library.
Done when: a check can be created, edited, paused and deleted in the browser, the page renders with zero checks, and the histogram matches a count query for the last 24 hours.
### Phase 5 · Hardening and deploy
Build: generous per-IP rate limiting on /ping, a /healthz endpoint, a retention job for old pings, a systemd unit and the README.
Done when: the service restarts with state intact, retention deletes, and the README takes a reader from clone to a monitored job.
### Out of scope (and why)
- SMS, WhatsApp and phone calls. You would be paying a provider anyway; one chat webhook covers the solo case.
- Teams, projects and the integrations catalogue.
- Running somewhere else. This is the real limit: a monitor on the same host as your jobs reports nothing when that host dies. Put it on a different box or a different provider, and say so in the README.
### README must contain
- The crontab one-liner ending in && curl -fsS <ping-url>, and the wrapper form that reports failures with /start and /fail.
- The same-host warning, stated once and plainly.
- A line noting Healthchecks.io is itself open source, and that self-hosting it is the fuller answer if you outgrow this.# Agent instructions · Healthchecks.io 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 separate from the 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 - Never local-time strings for timestamps; the late maths breaks across DST.
# Build plan · Healthchecks.io A heartbeat monitor for cron jobs: each job pings a URL when it finishes, the monitor calls it late and then down when the pings stop, one chat message per state change, and a dashboard with a 24-hour histogram. Healthchecks.io itself is open source, so self-hosting it is the fuller answer; this is the 300-line version you understand completely. 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 the Healthchecks scheme so existing crontab snippets port unchanged. ### Steps 1. Create the project and the tables checks (id uuid, name, period_seconds, grace_seconds, status, last_ping_at, last_started_at, last_duration_ms), pings (id, check_id, received_at, kind, exit_code, body), alerts (id, check_id, from_status, to_status, sent_at, delivered, error). UTC epoch milliseconds throughout. ```sh mkdir heartbeats && cd heartbeats && git init && npm init -y && npm pkg set type=module mkdir -p data && cp .env.example .env ``` 2. Route GET, POST and HEAD on /ping/:uuid plus /start, /fail, /log and /:exit_code 0 is success, 1-255 failure. Store at most 100 kB of body. Always answer 200 OK; unknown uuids 404. 3. Add a small CLI to create a check for testing ```sh node scripts/add-check.mjs "nightly backup" 86400 1800 ``` ### Done when - [ ] curl -fsS to a real uuid prints OK and inserts one row - [ ] A 2 MB body stores exactly 100 kB and returns 200 - [ ] A random uuid returns 404 without creating anything ### Watch out - Never local-time strings for timestamps; the late maths breaks across DST. ## Phase 2 · State machine up, late, down, computed every 30 seconds, one alerts row per transition. ### Steps 1. Write the evaluation: up within period, late past period, down past period plus grace; fail pings flip down at once; new and paused never alert 2. Record transitions in alerts and durations from /start to success ### Done when - [ ] Period 60 with grace 30 reads up, late at 61 s, down at 91 s - [ ] Exactly one alerts row per transition - [ ] A new check never alerts ## Phase 3 · Alerting One message per state change with retries that never stall the loop. ### Steps 1. Write the sender for ALERT_FORMAT and drain undelivered alerts after each loop 2. Retry three times with backoff, then record the error on the row ### Done when - [ ] Taking a check down produces exactly one message - [ ] Recovery produces exactly one - [ ] A webhook that 500s leaves an error on the row and the loop running ## Phase 4 · Dashboard Manage checks in the browser and see every job at a glance. ### Steps 1. Basic auth on /admin with CRUD and the ping URL plus a crontab line per check 2. A status dot, relative last ping and a 24-hour histogram as inline SVG ### Done when - [ ] Create, edit, pause and delete work in the browser - [ ] Renders with zero checks - [ ] Histogram matches a count query ## Phase 5 · Hardening and deploy Rate limits, retention, HTTPS, a real crontab. ### Steps 1. Rate limit /ping generously per IP; add /healthz and nightly retention 2. systemd unit, Caddy, one real crontab line, the README README: the one-liner with && curl -fsS, the /start and /fail wrapper, the same-host warning, and a line that Healthchecks.io is open source if you outgrow this. Files: `README.md`, `deploy/heartbeats.service`, `Caddyfile` ```sh # */5 * * * * /path/job.sh && curl -fsS https://hc.yourdomain.com/ping/<uuid> ``` ### Done when - [ ] State survives a reboot - [ ] A real cron job on another machine shows up - [ ] The README reaches a monitored job ## Not in this build - SMS, WhatsApp and phone-call alerts. - Teams, projects and the integrations catalogue. - Being off your infrastructure by itself: put it on a different box. ## After v1, if you want it - A second alert channel behind the Notifier interface - A read-only status page 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. PORT=3000 # Required. SQLite file. DATABASE_PATH=./data/checks.db # Required · secret. Chat webhook from the prerequisites. ALERT_WEBHOOK_URL=https://hooks.slack.com/services/... # Required. discord, slack or telegram. ALERT_FORMAT=slack # Required. Public base URL printed in ping URLs. SITE_URL=https://hc.yourdomain.com # Optional. How long pings are kept. RETENTION_DAYS=30 # 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
# Healthchecks.io · product brief ## Problem The server is open source and the protocol is a GET to a URL. A single-process monitor with a period, a grace time and one webhook covers the personal case in a sitting; the hosted product sells the alerting fan-out and not being on the same box as your jobs. ## Product outcome Heartbeat monitoring others could rely on, watched from a box that is not theirs, itself monitored and backed up. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - an always-on host that is not the box running the jobs - a chat webhook for alerts ## Explicit non-goals for v1 - SMS, WhatsApp and phone-call alerts. - Teams, projects and the integrations catalogue. - Being off your infrastructure by itself: put it on a different box. - SMS, WhatsApp and phone-call alerts - a monitor that lives off your infrastructure - the integrations catalogue (PagerDuty, Opsgenie, Slack app) - team accounts and project sharing ## Success criteria - Runs on a different provider than the jobs - Soak test: one flapping and one stable check alert correctly - One restore drill performed
# Build brief · Healthchecks.io
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 heartbeat monitor like Healthchecks.io. Build it in phases, in the order below. Do not write the whole thing 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. No web framework.
- One process for the HTTP listener and the checker loop. SQLite in WAL mode at a path from .env.
### 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
- pings: id, check_id, received_at, kind ('start' | 'success' | 'fail' | 'log'), exit_code, body
- alerts: id, check_id, from_status, to_status, sent_at, delivered, error
All timestamps are UTC epoch milliseconds · a local-time string breaks the late maths across a DST change.
### Phase 1 · Ping ingestion
Build: GET, POST and HEAD on /ping/:uuid, plus /ping/:uuid/start, /ping/:uuid/fail, /ping/:uuid/log and /ping/:uuid/:exit_code where 0 is success and 1-255 is failure. Store at most the first 100 kB of a POST body. Always answer 200 with the body OK; an unknown uuid answers 404.
Done when: a curl to a real uuid returns OK and inserts one row, a 2 MB body stores exactly 100 kB, and a random uuid returns 404 without creating anything.
Do not build yet: status changes, alerts, UI.
### Phase 2 · Status state machine
Build: a loop every 30 seconds. up while now is within period of the last ping; late once past period; down once past period plus grace. An explicit fail ping goes down immediately; any success goes up immediately. new checks never alert; paused checks are skipped. Every transition writes one alerts row. A /start followed by a success records the duration.
Done when: a 60 s period with 30 s grace reads up after a ping, late at 61 s and down at 91 s, 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 (Slack, Discord or Telegram URL in .env) that drains undelivered alerts rows. One message per state change, never per poll, carrying the check name, the new status, how late in human units, and the last failure body. A recovery message on the return to up. Three retries with backoff, then record the error and move on.
Done when: taking a check down produces exactly one message, leaving it down produces none, recovery produces exactly one, and a webhook URL that 500s leaves a row with an error and the loop still running.
### Phase 4 · Dashboard
Build: /admin behind basic auth from .env with CRUD for checks, and per check its ping URL with a copy button, a crontab example line, a status dot, a relative last-ping time and a 24-hour ping histogram as inline SVG. No chart library.
Done when: a check can be created, edited, paused and deleted in the browser, the page renders with zero checks, and the histogram matches a count query for the last 24 hours.
### Phase 5 · Hardening and deploy
Build: generous per-IP rate limiting on /ping, a /healthz endpoint, a retention job for old pings, a systemd unit and the README.
Done when: the service restarts with state intact, retention deletes, and the README takes a reader from clone to a monitored job.
### Out of scope (and why)
- SMS, WhatsApp and phone calls. You would be paying a provider anyway; one chat webhook covers the solo case.
- Teams, projects and the integrations catalogue.
- Running somewhere else. This is the real limit: a monitor on the same host as your jobs reports nothing when that host dies. Put it on a different box or a different provider, and say so in the README.
### README must contain
- The crontab one-liner ending in && curl -fsS <ping-url>, and the wrapper form that reports failures with /start and /fail.
- The same-host warning, stated once and plainly.
- A line noting Healthchecks.io is itself open source, and that self-hosting it is the fuller answer if you outgrow this.# Architecture · Healthchecks.io ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22, node:http and node:sqlite | a ping is one INSERT; the loop is a timer | | Database | SQLite in WAL mode | one file that never fails a ping because it is busy | | Alerts | One chat webhook | the channel you already watch | | Hosting | A VPS separate from the jobs | a monitor on the same host reports nothing when it dies | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Ingest | /ping routes | Healthchecks-compatible, so clients never change | | Evaluator | the state machine | Pure function, fake-clock testable | | Notifier | formatting and retries | One function per format | | Admin | dashboard and forms | Any UI over the tables | ## 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. - `DATABASE_PATH` · required · SQLite file. - `ALERT_WEBHOOK_URL` · required, secret · Chat webhook from the prerequisites. - `ALERT_FORMAT` · required · discord, slack or telegram. - `SITE_URL` · required · Public base URL printed in ping URLs. - `RETENTION_DAYS` · optional · How long pings are kept. - `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. ## 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 · Healthchecks.io 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 separate from the 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 - Never local-time strings for timestamps; the late maths breaks across DST.
# Delivery milestones · Healthchecks.io Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Ping ingestion Accept pings on the Healthchecks scheme so existing crontab snippets port unchanged. ### Steps 1. Create the project and the tables checks (id uuid, name, period_seconds, grace_seconds, status, last_ping_at, last_started_at, last_duration_ms), pings (id, check_id, received_at, kind, exit_code, body), alerts (id, check_id, from_status, to_status, sent_at, delivered, error). UTC epoch milliseconds throughout. ```sh mkdir heartbeats && cd heartbeats && git init && npm init -y && npm pkg set type=module mkdir -p data && cp .env.example .env ``` 2. Route GET, POST and HEAD on /ping/:uuid plus /start, /fail, /log and /:exit_code 0 is success, 1-255 failure. Store at most 100 kB of body. Always answer 200 OK; unknown uuids 404. 3. Add a small CLI to create a check for testing ```sh node scripts/add-check.mjs "nightly backup" 86400 1800 ``` ### Done when - [ ] curl -fsS to a real uuid prints OK and inserts one row - [ ] A 2 MB body stores exactly 100 kB and returns 200 - [ ] A random uuid returns 404 without creating anything ### Watch out - Never local-time strings for timestamps; the late maths breaks across DST. ## M2 · State machine up, late, down, computed every 30 seconds, one alerts row per transition. ### Steps 1. Write the evaluation: up within period, late past period, down past period plus grace; fail pings flip down at once; new and paused never alert 2. Record transitions in alerts and durations from /start to success ### Done when - [ ] Period 60 with grace 30 reads up, late at 61 s, down at 91 s - [ ] Exactly one alerts row per transition - [ ] A new check never alerts ## M3 · Alerting One message per state change with retries that never stall the loop. ### Steps 1. Write the sender for ALERT_FORMAT and drain undelivered alerts after each loop 2. Retry three times with backoff, then record the error on the row ### Done when - [ ] Taking a check down produces exactly one message - [ ] Recovery produces exactly one - [ ] A webhook that 500s leaves an error on the row and the loop running ## M4 · Dashboard Manage checks in the browser and see every job at a glance. ### Steps 1. Basic auth on /admin with CRUD and the ping URL plus a crontab line per check 2. A status dot, relative last ping and a 24-hour histogram as inline SVG ### Done when - [ ] Create, edit, pause and delete work in the browser - [ ] Renders with zero checks - [ ] Histogram matches a count query ## M5 · Hardening and deploy Rate limits, retention, HTTPS, a real crontab. ### Steps 1. Rate limit /ping generously per IP; add /healthz and nightly retention 2. systemd unit, Caddy, one real crontab line, the README README: the one-liner with && curl -fsS, the /start and /fail wrapper, the same-host warning, and a line that Healthchecks.io is open source if you outgrow this. Files: `README.md`, `deploy/heartbeats.service`, `Caddyfile` ```sh # */5 * * * * /path/job.sh && curl -fsS https://hc.yourdomain.com/ping/<uuid> ``` ### Done when - [ ] State survives a reboot - [ ] A real cron job on another machine shows up - [ ] The README reaches a monitored job ## 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 · Healthchecks.io ## Backup SQLite .backup nightly off the box. ## Restore Copy back; the dashboard lists every check. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring An external check on /healthz from another provider. ## Incident checklist If the monitor is down, jobs are unwatched not broken; restore and review the gap. 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 - [ ] Runs on a different provider than the jobs - [ ] Soak test: one flapping and one stable check alert correctly - [ ] One restore drill performed ## Launch constraint Do not market omitted Healthchecks.io 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. PORT=3000 # Required. SQLite file. DATABASE_PATH=./data/checks.db # Required · secret. Chat webhook from the prerequisites. ALERT_WEBHOOK_URL=https://hooks.slack.com/services/... # Required. discord, slack or telegram. ALERT_FORMAT=slack # Required. Public base URL printed in ping URLs. SITE_URL=https://hc.yourdomain.com # Optional. How long pings are kept. RETENTION_DAYS=30 # 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
$ choose a build depth, inspect the files, then open the complete pack in your agent
A monitor on your own server dies with your server. People pay for the one box that is guaranteed to be somewhere else, and for the phone call when it matters.
xSMS, WhatsApp and phone-call alerts
xa monitor that lives off your infrastructure
xthe integrations catalogue (PagerDuty, Opsgenie, Slack app)
xteam accounts and project sharing
Healthchecks.io pricing
business$20/mo · monthly flat · $240/yr
free tierThe free Hobbyist plan covers 20 monitored jobs with 100 log entries each and no SMS or phone credits.
verified 2026-09-04 · source ↗
Is Healthchecks.io free?
The free Hobbyist plan covers 20 monitored jobs with 100 log entries each and no SMS or phone credits. Paid is Business at $20/mo (checked 2026-09-04).
Vibecode Healthchecks.io
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Healthchecks.io replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Healthchecks.io cost?
Healthchecks.io costs about $20/month (Business, checked 2026-09-04), which is $240 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing Healthchecks.io?
Honestly: SMS, WhatsApp and phone-call alerts; a monitor that lives off your infrastructure; the integrations catalogue (PagerDuty, Opsgenie, Slack app); team accounts and project sharing. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Healthchecks.io?
Yes: healthchecks (the hosted product is this, open source, self-hostable). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.