Vibecode Better Stack
track this build6 phases, 14 steps, beginner friendly0%A cron loop, a fetch, an alert webhook, and a status page. The $30/mo is for the dashboard gloss.
You are building a lean indie version of Better Stack.
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 =====
# Better Stack · indie build
An uptime monitor you run yourself: a fetch on an interval with a real timeout, a state machine that only calls a site down after two consecutive failures, one chat message on down and one on recovery, and a public status page with uptime percentages, a latency sparkline and an RSS feed of incidents. Honest about the one thing a single box cannot do: tell your outage from your own network's.
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 with node:http, node:sqlite and global fetch | the check is a fetch; the page is a few indexed queries |
| Database | SQLite in WAL mode | checks, incidents and monitors in one file |
| Alerts | One chat webhook | the channel you already watch |
| Hosting | A VPS on a different provider than the sites it watches | otherwise it goes down with them |
## Before you start
Have every one of these ready. The plan assumes them from step one.
- [ ] **Node.js 22 or newer** · free
- Why: Everything in this build runs on it: the server, the scripts, the tests.
- Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards.
- Verify: node --version prints v22 or higher
- [ ] **A terminal and a code editor** · free
- Why: Every step below is a command you type or a file you edit.
- Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal.
- Verify: You can open a folder and run a command in its terminal
- [ ] **Git** · free
- Why: History for your code, and the way most hosts deploy.
- Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists.
- Verify: git --version prints a version
- [ ] **A chat webhook URL (Discord, Slack or Telegram)** · free
- Why: Alerts go to a chat channel you already watch. A webhook URL is the only credential this needs.
- Get it: Discord: Server settings > Integrations > Webhooks > New Webhook, copy the URL. Slack: create an app at api.slack.com/apps, enable Incoming Webhooks, add to a channel, copy the URL. Telegram: create a bot with @BotFather and use the bot token plus your chat id.
- Verify: curl -X POST -H 'Content-Type: application/json' -d '{"content":"test"}' <url> posts a message (Discord form; Slack uses a text field)
- [ ] **The URLs to watch, with what healthy means for each** · free
- Why: Each monitor needs a URL, an interval, an expected status and optionally a keyword the body must contain.
- Get it: List every site or endpoint. For each decide: check every 60 seconds or 300, expect 200, and a word that only appears when the page really works.
- [ ] **A small VPS on a different provider than your sites** · about $5 a month
- Why: A monitor hosted next to what it watches reports nothing when that host dies. Different provider, ideally a different region.
- Get it: Hetzner if your sites are elsewhere, or vice versa. Smallest Ubuntu 24.04 instance with SSH.
- [ ] **A domain or subdomain** (optional) · roughly $10 a year, or free on an existing domain
- Why: A status page address you can give people, e.g. status.yourdomain.com.
- 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 uptime && cd uptime && git init && npm init -y && npm pkg set type=module
mkdir data && cp .env.example .env
```
Then copy `.env.example` to `.env` and fill in the values it documents.
## Honest limits
This build deliberately does not replace:
- Multi-region probes. One box cannot tell an outage from its own bad network; the status page says so.
- Subscriber email notifications, incident templates, on-call.
- global multi-region probes
- on-call scheduling & escalation policies
- incident timelines and postmortem tooling
- phone-call alerts
If one of those is essential to you, that is the reason to keep paying for Better Stack, and the README should say so rather than pretend.
===== BRIEF.md =====
# Build brief · Better Stack
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 an uptime monitor like UptimeRobot or Better Stack. Build it in phases,
in the order below. Do not write the whole service in one pass. Finish a phase,
run its "Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- Node 22 with `node:http`, `node:sqlite` and global `fetch`, or Python 3.12 with
stdlib plus `httpx`. Pick one, no web framework.
- One process: the checker loop and the status page share it.
- SQLite at a path from `.env`, WAL mode.
### Data model (create this before Phase 1)
- `monitors`: id, name, url, method, interval_seconds, timeout_ms,
expected_status, keyword (nullable), enabled, status ('unknown' | 'up' |
'down'), consecutive_failures, last_checked_at, last_change_at
- `checks`: id, monitor_id, checked_at, ok (bool), status_code, latency_ms, error
- `incidents`: id, monitor_id, started_at, ended_at, cause
Index `checks(monitor_id, checked_at)`. All timestamps are UTC epoch
milliseconds. `incidents` is a separate table from `checks` on purpose: uptime
percentage comes from checks, but the human question ("how long was it down, and
why") comes from incidents, and deriving that from raw checks at read time gets
slow and wrong at the edges.
### Phase 1 · The check itself
Build: one function that takes a monitor and returns a result. Set an explicit
timeout with `AbortController` · a hung TCP connection with no timeout is the
bug that makes a monitor silently stop monitoring. Follow redirects, cap the body
read (64KB is plenty for a keyword match), record latency, and treat DNS failure,
TLS failure, timeout and a wrong status code as distinct `error` values rather
than one generic failure.
Done when: checking a known-good URL records ok with a sane latency; a URL that
never responds fails at the timeout, not later; a 404 against an
`expected_status` of 200 fails with the code recorded; and a keyword monitor
fails when the keyword is absent from a 200 response.
Do not build yet: scheduling, alerts, the page.
### Phase 2 · Scheduler
Build: the loop that runs due monitors on their own intervals, concurrently but
with a cap (say 10 at once), so one slow host cannot delay every other check.
Never let one monitor's exception kill the loop. Recover cleanly from a restart ·
on boot, treat every monitor as due rather than waiting a full interval.
Done when: three monitors with 30s, 60s and 300s intervals each run at their own
cadence for ten minutes; one monitor pointed at a host that hangs does not delay
the others; and throwing an exception inside one check leaves the loop running.
Do not build yet: alerting.
### Phase 3 · State transitions and flap suppression
Build: the state machine. A monitor goes `down` only after N consecutive failures
(default 2, configurable per monitor) and returns to `up` on the first success.
Open an `incidents` row on the transition to down and close it on recovery,
recording the cause from the first failing check.
Done when: a single blip does not open an incident, two consecutive failures do,
the incident closes on recovery with a correct duration, and a monitor flapping
up/down/up/down produces one incident rather than four.
### Phase 4 · Alerting
Build: a webhook sender (Discord, Slack or Telegram, URL in `.env`) firing once
on down and once on recovery · never on every failed check. Include the monitor
name, the error, and the outage duration on recovery. Retry a failed delivery
three times with backoff, then record the error and continue.
Done when: taking a monitored service down produces exactly one message, keeping
it down produces none, recovery produces exactly one with a correct duration, and
a broken webhook URL does not stall the checker loop.
### Phase 5 · Status page
Build: a public `/status` · one row per monitor with a green/red dot, current
state, uptime percentage over 24h, 7d and 30d, a latency sparkline as inline SVG,
and the recent incident list with durations. Auto-refresh every 30 seconds via a
meta refresh or a tiny fetch, no framework. Compute uptime from `checks`, and
state the denominator on the page ("over 2,880 checks") so the number can be
argued with.
Done when: percentages match a hand-written SQL query, the page renders correctly
with zero history on a fresh install, and it loads in under 200ms with 90 days of
checks in the database.
### Phase 6 · Retention and deploy
Build: a nightly prune keeping 90 days of `checks` (keep `incidents` forever ·
they are small and they are the history you actually reread), a `/healthz`
endpoint, a systemd unit with restart-on-failure, and the README.
Done when: pruning does not distort the 30-day uptime figure, and the service
comes back with state intact after a reboot.
### Out of scope (and why)
- Multi-region probes. One box cannot distinguish "the site is down" from "my
box's network is down", and that distinction is a real part of what the paid
product sells. Say so on the status page, not just in the README.
- On-call scheduling, escalation policies and phone-call alerts.
- Incident timelines and postmortem tooling.
### README must contain
- The single-region caveat, stated plainly.
- The warning that a monitor hosted on the same machine as the monitored service
reports nothing when it matters most · put it on a different box or a different
provider than the thing it watches.
===== AGENTS.md =====
# Agent instructions · Better Stack indie build
- Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with node:http, node:sqlite and global fetch, SQLite in WAL mode, One chat webhook, A VPS on a different provider than the sites it watches. 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".
===== BUILD_PLAN.md =====
# Build plan · Better Stack
An uptime monitor you run yourself: a fetch on an interval with a real timeout, a state machine that only calls a site down after two consecutive failures, one chat message on down and one on recovery, and a public status page with uptime percentages, a latency sparkline and an RSS feed of incidents. Honest about the one thing a single box cannot do: tell your outage from your own network's.
Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note.
## Phase 1 · The check
One function from a monitor to a result, with a timeout that actually fires and distinct error kinds.
### Steps
1. Create the project and the three tables
monitors (id, name, url, interval_seconds, timeout_ms, expected_status, keyword, enabled, status, consecutive_failures, last_checked_at, last_change_at), checks (id, monitor_id, checked_at, ok, status_code, latency_ms, error), incidents (id, monitor_id, started_at, ended_at, cause). Index checks(monitor_id, checked_at).
Files: `server.mjs`, `db.mjs`, `check.mjs`
```sh
mkdir uptime && cd uptime && git init && npm init -y && npm pkg set type=module
mkdir data && cp .env.example .env
```
2. Write check(monitor)
fetch with an AbortController timeout, follow redirects, read at most 64 kB of body for the keyword, record latency. Return ok plus one of: dns, tls, timeout, status, keyword, as the error.
3. Add a CLI to run one check by hand
```sh
node scripts/check.mjs https://example.com 200 "Example Domain"
```
### Done when
- [ ] A good URL records ok with a sane latency
- [ ] A URL that never responds fails at the timeout, not later
- [ ] A 404 against expected 200 fails with the code recorded
- [ ] A keyword monitor fails when the word is absent from a 200 body
## Phase 2 · Scheduler
Each monitor on its own interval, concurrently with a cap, and one bad monitor never stops the loop.
### Steps
1. Write the loop
Every 5 seconds find monitors due (last_checked_at plus interval before now), run up to 10 concurrently, catch every exception per monitor. On boot treat every monitor as due.
2. Store a checks row per run and update the monitor's last_checked_at
### Done when
- [ ] Monitors at 30, 60 and 300 seconds keep their cadence over ten minutes
- [ ] A monitor pointed at a hanging host does not delay the others
- [ ] A thrown exception inside one check leaves the loop running
## Phase 3 · Transitions and incidents
Down only after N consecutive failures, up on the first success, one incident per outage.
### Steps
1. Implement the transition rule
Increment consecutive_failures on a failed check; at FAILURES_BEFORE_DOWN flip to down. Any success resets the counter and flips to up.
2. Open and close incidents on the transitions
Open a row on the flip to down with the first failure's error as cause; set ended_at on recovery. Incidents, not checks, are what people read later.
### Done when
- [ ] A single blip opens no incident
- [ ] Two consecutive failures open one
- [ ] Recovery closes it with a correct duration
- [ ] Flapping up/down/up/down produces one incident, not four
## Phase 4 · Alerting
One message down, one up, retries that never stall the loop.
### Steps
1. Write the sender for ALERT_FORMAT
Down: monitor name and the error. Three retries with backoff, then record the failure and move on.
2. Send the recovery message with the outage duration
Duration comes from the incident row's started_at and ended_at.
### Done when
- [ ] Taking a site down produces exactly one message
- [ ] Keeping it down produces none
- [ ] Recovery produces exactly one with the duration
- [ ] A webhook URL that 500s does not stall checking
## Phase 5 · Status page
A public page whose numbers can be argued with, and a feed people can subscribe to.
### Steps
1. Build /status
One row per monitor: dot, state, uptime over 24 h, 7 d and 30 d with the denominator printed (over 2,880 checks), a latency sparkline as inline SVG, recent incidents with durations. Meta refresh every 30 seconds.
2. Add /status.rss with one item per incident
Subscription without accounts.
3. Seed 90 days of checks and time the page
```sh
node scripts/seed.mjs 90
```
### Done when
- [ ] Percentages match a hand-written query
- [ ] The page renders with zero history on a fresh install
- [ ] It loads in under 200 ms with 90 days of checks
- [ ] The RSS feed validates
## Phase 6 · Retention and deploy
Old checks pruned, incidents kept, live behind HTTPS on the other provider.
### Steps
1. Nightly prune of checks older than RETENTION_DAYS, never incidents
2. Add /healthz, systemd with restart-on-failure, Caddy, and the README
README: the single-region caveat stated plainly, the instruction to host on a different provider than the things watched, and the RSS URL.
Files: `deploy/uptime.service`, `Caddyfile`, `README.md`
### Done when
- [ ] Pruning does not distort the 30-day figure
- [ ] The service survives a reboot with state intact
- [ ] The status page states the single-region caveat
## Not in this build
- Multi-region probes. One box cannot tell an outage from its own bad network; the status page says so.
- Subscriber email notifications, incident templates, on-call.
## After v1, if you want it
- A second probe location that must agree before an incident opens
- Maintenance windows that suppress alerts
===== .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/uptime.db
# Required · secret. The chat webhook from the prerequisites.
ALERT_WEBHOOK_URL=https://hooks.slack.com/services/...
# Required. discord, slack or telegram.
ALERT_FORMAT=slack
# Optional. Consecutive failures before a monitor is called down.
FAILURES_BEFORE_DOWN=2
# Optional. Days of raw checks to keep. Incidents are kept forever.
RETENTION_DAYS=90
# Required. Public base URL for the status page and RSS feed.
SITE_URL=https://status.yourdomain.com
# 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 Better Stack.
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 =====
# Better Stack · indie build
An uptime monitor you run yourself: a fetch on an interval with a real timeout, a state machine that only calls a site down after two consecutive failures, one chat message on down and one on recovery, and a public status page with uptime percentages, a latency sparkline and an RSS feed of incidents. Honest about the one thing a single box cannot do: tell your outage from your own network's.
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 with node:http, node:sqlite and global fetch | the check is a fetch; the page is a few indexed queries |
| Database | SQLite in WAL mode | checks, incidents and monitors in one file |
| Alerts | One chat webhook | the channel you already watch |
| Hosting | A VPS on a different provider than the sites it watches | otherwise it goes down with them |
## Before you start
Have every one of these ready. The plan assumes them from step one.
- [ ] **Node.js 22 or newer** · free
- Why: Everything in this build runs on it: the server, the scripts, the tests.
- Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards.
- Verify: node --version prints v22 or higher
- [ ] **A terminal and a code editor** · free
- Why: Every step below is a command you type or a file you edit.
- Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal.
- Verify: You can open a folder and run a command in its terminal
- [ ] **Git** · free
- Why: History for your code, and the way most hosts deploy.
- Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists.
- Verify: git --version prints a version
- [ ] **A chat webhook URL (Discord, Slack or Telegram)** · free
- Why: Alerts go to a chat channel you already watch. A webhook URL is the only credential this needs.
- Get it: Discord: Server settings > Integrations > Webhooks > New Webhook, copy the URL. Slack: create an app at api.slack.com/apps, enable Incoming Webhooks, add to a channel, copy the URL. Telegram: create a bot with @BotFather and use the bot token plus your chat id.
- Verify: curl -X POST -H 'Content-Type: application/json' -d '{"content":"test"}' <url> posts a message (Discord form; Slack uses a text field)
- [ ] **The URLs to watch, with what healthy means for each** · free
- Why: Each monitor needs a URL, an interval, an expected status and optionally a keyword the body must contain.
- Get it: List every site or endpoint. For each decide: check every 60 seconds or 300, expect 200, and a word that only appears when the page really works.
- [ ] **A small VPS on a different provider than your sites** · about $5 a month
- Why: A monitor hosted next to what it watches reports nothing when that host dies. Different provider, ideally a different region.
- Get it: Hetzner if your sites are elsewhere, or vice versa. Smallest Ubuntu 24.04 instance with SSH.
- [ ] **A domain or subdomain** (optional) · roughly $10 a year, or free on an existing domain
- Why: A status page address you can give people, e.g. status.yourdomain.com.
- 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 uptime && cd uptime && git init && npm init -y && npm pkg set type=module
mkdir data && cp .env.example .env
```
Then copy `.env.example` to `.env` and fill in the values it documents.
## Honest limits
This build deliberately does not replace:
- Multi-region probes. One box cannot tell an outage from its own bad network; the status page says so.
- Subscriber email notifications, incident templates, on-call.
- global multi-region probes
- on-call scheduling & escalation policies
- incident timelines and postmortem tooling
- phone-call alerts
If one of those is essential to you, that is the reason to keep paying for Better Stack, and the README should say so rather than pretend.
===== BRIEF.md =====
# Build brief · Better Stack
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 an uptime monitor like UptimeRobot or Better Stack. Build it in phases,
in the order below. Do not write the whole service in one pass. Finish a phase,
run its "Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- Node 22 with `node:http`, `node:sqlite` and global `fetch`, or Python 3.12 with
stdlib plus `httpx`. Pick one, no web framework.
- One process: the checker loop and the status page share it.
- SQLite at a path from `.env`, WAL mode.
### Data model (create this before Phase 1)
- `monitors`: id, name, url, method, interval_seconds, timeout_ms,
expected_status, keyword (nullable), enabled, status ('unknown' | 'up' |
'down'), consecutive_failures, last_checked_at, last_change_at
- `checks`: id, monitor_id, checked_at, ok (bool), status_code, latency_ms, error
- `incidents`: id, monitor_id, started_at, ended_at, cause
Index `checks(monitor_id, checked_at)`. All timestamps are UTC epoch
milliseconds. `incidents` is a separate table from `checks` on purpose: uptime
percentage comes from checks, but the human question ("how long was it down, and
why") comes from incidents, and deriving that from raw checks at read time gets
slow and wrong at the edges.
### Phase 1 · The check itself
Build: one function that takes a monitor and returns a result. Set an explicit
timeout with `AbortController` · a hung TCP connection with no timeout is the
bug that makes a monitor silently stop monitoring. Follow redirects, cap the body
read (64KB is plenty for a keyword match), record latency, and treat DNS failure,
TLS failure, timeout and a wrong status code as distinct `error` values rather
than one generic failure.
Done when: checking a known-good URL records ok with a sane latency; a URL that
never responds fails at the timeout, not later; a 404 against an
`expected_status` of 200 fails with the code recorded; and a keyword monitor
fails when the keyword is absent from a 200 response.
Do not build yet: scheduling, alerts, the page.
### Phase 2 · Scheduler
Build: the loop that runs due monitors on their own intervals, concurrently but
with a cap (say 10 at once), so one slow host cannot delay every other check.
Never let one monitor's exception kill the loop. Recover cleanly from a restart ·
on boot, treat every monitor as due rather than waiting a full interval.
Done when: three monitors with 30s, 60s and 300s intervals each run at their own
cadence for ten minutes; one monitor pointed at a host that hangs does not delay
the others; and throwing an exception inside one check leaves the loop running.
Do not build yet: alerting.
### Phase 3 · State transitions and flap suppression
Build: the state machine. A monitor goes `down` only after N consecutive failures
(default 2, configurable per monitor) and returns to `up` on the first success.
Open an `incidents` row on the transition to down and close it on recovery,
recording the cause from the first failing check.
Done when: a single blip does not open an incident, two consecutive failures do,
the incident closes on recovery with a correct duration, and a monitor flapping
up/down/up/down produces one incident rather than four.
### Phase 4 · Alerting
Build: a webhook sender (Discord, Slack or Telegram, URL in `.env`) firing once
on down and once on recovery · never on every failed check. Include the monitor
name, the error, and the outage duration on recovery. Retry a failed delivery
three times with backoff, then record the error and continue.
Done when: taking a monitored service down produces exactly one message, keeping
it down produces none, recovery produces exactly one with a correct duration, and
a broken webhook URL does not stall the checker loop.
### Phase 5 · Status page
Build: a public `/status` · one row per monitor with a green/red dot, current
state, uptime percentage over 24h, 7d and 30d, a latency sparkline as inline SVG,
and the recent incident list with durations. Auto-refresh every 30 seconds via a
meta refresh or a tiny fetch, no framework. Compute uptime from `checks`, and
state the denominator on the page ("over 2,880 checks") so the number can be
argued with.
Done when: percentages match a hand-written SQL query, the page renders correctly
with zero history on a fresh install, and it loads in under 200ms with 90 days of
checks in the database.
### Phase 6 · Retention and deploy
Build: a nightly prune keeping 90 days of `checks` (keep `incidents` forever ·
they are small and they are the history you actually reread), a `/healthz`
endpoint, a systemd unit with restart-on-failure, and the README.
Done when: pruning does not distort the 30-day uptime figure, and the service
comes back with state intact after a reboot.
### Out of scope (and why)
- Multi-region probes. One box cannot distinguish "the site is down" from "my
box's network is down", and that distinction is a real part of what the paid
product sells. Say so on the status page, not just in the README.
- On-call scheduling, escalation policies and phone-call alerts.
- Incident timelines and postmortem tooling.
### README must contain
- The single-region caveat, stated plainly.
- The warning that a monitor hosted on the same machine as the monitored service
reports nothing when it matters most · put it on a different box or a different
provider than the thing it watches.
===== AGENTS.md =====
# Agent instructions · Better Stack indie build
- Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with node:http, node:sqlite and global fetch, SQLite in WAL mode, One chat webhook, A VPS on a different provider than the sites it watches. 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".
===== BUILD_PLAN.md =====
# Build plan · Better Stack
An uptime monitor you run yourself: a fetch on an interval with a real timeout, a state machine that only calls a site down after two consecutive failures, one chat message on down and one on recovery, and a public status page with uptime percentages, a latency sparkline and an RSS feed of incidents. Honest about the one thing a single box cannot do: tell your outage from your own network's.
Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note.
## Phase 1 · The check
One function from a monitor to a result, with a timeout that actually fires and distinct error kinds.
### Steps
1. Create the project and the three tables
monitors (id, name, url, interval_seconds, timeout_ms, expected_status, keyword, enabled, status, consecutive_failures, last_checked_at, last_change_at), checks (id, monitor_id, checked_at, ok, status_code, latency_ms, error), incidents (id, monitor_id, started_at, ended_at, cause). Index checks(monitor_id, checked_at).
Files: `server.mjs`, `db.mjs`, `check.mjs`
```sh
mkdir uptime && cd uptime && git init && npm init -y && npm pkg set type=module
mkdir data && cp .env.example .env
```
2. Write check(monitor)
fetch with an AbortController timeout, follow redirects, read at most 64 kB of body for the keyword, record latency. Return ok plus one of: dns, tls, timeout, status, keyword, as the error.
3. Add a CLI to run one check by hand
```sh
node scripts/check.mjs https://example.com 200 "Example Domain"
```
### Done when
- [ ] A good URL records ok with a sane latency
- [ ] A URL that never responds fails at the timeout, not later
- [ ] A 404 against expected 200 fails with the code recorded
- [ ] A keyword monitor fails when the word is absent from a 200 body
## Phase 2 · Scheduler
Each monitor on its own interval, concurrently with a cap, and one bad monitor never stops the loop.
### Steps
1. Write the loop
Every 5 seconds find monitors due (last_checked_at plus interval before now), run up to 10 concurrently, catch every exception per monitor. On boot treat every monitor as due.
2. Store a checks row per run and update the monitor's last_checked_at
### Done when
- [ ] Monitors at 30, 60 and 300 seconds keep their cadence over ten minutes
- [ ] A monitor pointed at a hanging host does not delay the others
- [ ] A thrown exception inside one check leaves the loop running
## Phase 3 · Transitions and incidents
Down only after N consecutive failures, up on the first success, one incident per outage.
### Steps
1. Implement the transition rule
Increment consecutive_failures on a failed check; at FAILURES_BEFORE_DOWN flip to down. Any success resets the counter and flips to up.
2. Open and close incidents on the transitions
Open a row on the flip to down with the first failure's error as cause; set ended_at on recovery. Incidents, not checks, are what people read later.
### Done when
- [ ] A single blip opens no incident
- [ ] Two consecutive failures open one
- [ ] Recovery closes it with a correct duration
- [ ] Flapping up/down/up/down produces one incident, not four
## Phase 4 · Alerting
One message down, one up, retries that never stall the loop.
### Steps
1. Write the sender for ALERT_FORMAT
Down: monitor name and the error. Three retries with backoff, then record the failure and move on.
2. Send the recovery message with the outage duration
Duration comes from the incident row's started_at and ended_at.
### Done when
- [ ] Taking a site down produces exactly one message
- [ ] Keeping it down produces none
- [ ] Recovery produces exactly one with the duration
- [ ] A webhook URL that 500s does not stall checking
## Phase 5 · Status page
A public page whose numbers can be argued with, and a feed people can subscribe to.
### Steps
1. Build /status
One row per monitor: dot, state, uptime over 24 h, 7 d and 30 d with the denominator printed (over 2,880 checks), a latency sparkline as inline SVG, recent incidents with durations. Meta refresh every 30 seconds.
2. Add /status.rss with one item per incident
Subscription without accounts.
3. Seed 90 days of checks and time the page
```sh
node scripts/seed.mjs 90
```
### Done when
- [ ] Percentages match a hand-written query
- [ ] The page renders with zero history on a fresh install
- [ ] It loads in under 200 ms with 90 days of checks
- [ ] The RSS feed validates
## Phase 6 · Retention and deploy
Old checks pruned, incidents kept, live behind HTTPS on the other provider.
### Steps
1. Nightly prune of checks older than RETENTION_DAYS, never incidents
2. Add /healthz, systemd with restart-on-failure, Caddy, and the README
README: the single-region caveat stated plainly, the instruction to host on a different provider than the things watched, and the RSS URL.
Files: `deploy/uptime.service`, `Caddyfile`, `README.md`
### Done when
- [ ] Pruning does not distort the 30-day figure
- [ ] The service survives a reboot with state intact
- [ ] The status page states the single-region caveat
## Not in this build
- Multi-region probes. One box cannot tell an outage from its own bad network; the status page says so.
- Subscriber email notifications, incident templates, on-call.
## After v1, if you want it
- A second probe location that must agree before an incident opens
- Maintenance windows that suppress alerts
===== .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/uptime.db
# Required · secret. The chat webhook from the prerequisites.
ALERT_WEBHOOK_URL=https://hooks.slack.com/services/...
# Required. discord, slack or telegram.
ALERT_FORMAT=slack
# Optional. Consecutive failures before a monitor is called down.
FAILURES_BEFORE_DOWN=2
# Optional. Days of raw checks to keep. Incidents are kept forever.
RETENTION_DAYS=90
# Required. Public base URL for the status page and RSS feed.
SITE_URL=https://status.yourdomain.com
# 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 Better Stack.
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 =====
# Better Stack · product brief
## Problem
A cron loop, a fetch, an alert webhook, and a status page. The $30/mo is for the dashboard gloss.
## Product outcome
A status page you could show customers: honest percentages, incident history, a feed, and a monitor that is itself monitored from a third place.
## Target user
A builder who needs a maintainable product foundation, not a one-off demo.
## Required capabilities
- Implement the core workflow described in ARCHITECTURE.md
## Explicit non-goals for v1
- Multi-region probes. One box cannot tell an outage from its own bad network; the status page says so.
- Subscriber email notifications, incident templates, on-call.
- global multi-region probes
- on-call scheduling & escalation policies
- incident timelines and postmortem tooling
- phone-call alerts
## Success criteria
- Runs on a different provider than any monitored site
- Flap test produces one incident
- One restore drill performed and dated
- Status page loads under 200 ms with 90 days of data
===== BRIEF.md =====
# Build brief · Better Stack
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 an uptime monitor like UptimeRobot or Better Stack. Build it in phases,
in the order below. Do not write the whole service in one pass. Finish a phase,
run its "Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- Node 22 with `node:http`, `node:sqlite` and global `fetch`, or Python 3.12 with
stdlib plus `httpx`. Pick one, no web framework.
- One process: the checker loop and the status page share it.
- SQLite at a path from `.env`, WAL mode.
### Data model (create this before Phase 1)
- `monitors`: id, name, url, method, interval_seconds, timeout_ms,
expected_status, keyword (nullable), enabled, status ('unknown' | 'up' |
'down'), consecutive_failures, last_checked_at, last_change_at
- `checks`: id, monitor_id, checked_at, ok (bool), status_code, latency_ms, error
- `incidents`: id, monitor_id, started_at, ended_at, cause
Index `checks(monitor_id, checked_at)`. All timestamps are UTC epoch
milliseconds. `incidents` is a separate table from `checks` on purpose: uptime
percentage comes from checks, but the human question ("how long was it down, and
why") comes from incidents, and deriving that from raw checks at read time gets
slow and wrong at the edges.
### Phase 1 · The check itself
Build: one function that takes a monitor and returns a result. Set an explicit
timeout with `AbortController` · a hung TCP connection with no timeout is the
bug that makes a monitor silently stop monitoring. Follow redirects, cap the body
read (64KB is plenty for a keyword match), record latency, and treat DNS failure,
TLS failure, timeout and a wrong status code as distinct `error` values rather
than one generic failure.
Done when: checking a known-good URL records ok with a sane latency; a URL that
never responds fails at the timeout, not later; a 404 against an
`expected_status` of 200 fails with the code recorded; and a keyword monitor
fails when the keyword is absent from a 200 response.
Do not build yet: scheduling, alerts, the page.
### Phase 2 · Scheduler
Build: the loop that runs due monitors on their own intervals, concurrently but
with a cap (say 10 at once), so one slow host cannot delay every other check.
Never let one monitor's exception kill the loop. Recover cleanly from a restart ·
on boot, treat every monitor as due rather than waiting a full interval.
Done when: three monitors with 30s, 60s and 300s intervals each run at their own
cadence for ten minutes; one monitor pointed at a host that hangs does not delay
the others; and throwing an exception inside one check leaves the loop running.
Do not build yet: alerting.
### Phase 3 · State transitions and flap suppression
Build: the state machine. A monitor goes `down` only after N consecutive failures
(default 2, configurable per monitor) and returns to `up` on the first success.
Open an `incidents` row on the transition to down and close it on recovery,
recording the cause from the first failing check.
Done when: a single blip does not open an incident, two consecutive failures do,
the incident closes on recovery with a correct duration, and a monitor flapping
up/down/up/down produces one incident rather than four.
### Phase 4 · Alerting
Build: a webhook sender (Discord, Slack or Telegram, URL in `.env`) firing once
on down and once on recovery · never on every failed check. Include the monitor
name, the error, and the outage duration on recovery. Retry a failed delivery
three times with backoff, then record the error and continue.
Done when: taking a monitored service down produces exactly one message, keeping
it down produces none, recovery produces exactly one with a correct duration, and
a broken webhook URL does not stall the checker loop.
### Phase 5 · Status page
Build: a public `/status` · one row per monitor with a green/red dot, current
state, uptime percentage over 24h, 7d and 30d, a latency sparkline as inline SVG,
and the recent incident list with durations. Auto-refresh every 30 seconds via a
meta refresh or a tiny fetch, no framework. Compute uptime from `checks`, and
state the denominator on the page ("over 2,880 checks") so the number can be
argued with.
Done when: percentages match a hand-written SQL query, the page renders correctly
with zero history on a fresh install, and it loads in under 200ms with 90 days of
checks in the database.
### Phase 6 · Retention and deploy
Build: a nightly prune keeping 90 days of `checks` (keep `incidents` forever ·
they are small and they are the history you actually reread), a `/healthz`
endpoint, a systemd unit with restart-on-failure, and the README.
Done when: pruning does not distort the 30-day uptime figure, and the service
comes back with state intact after a reboot.
### Out of scope (and why)
- Multi-region probes. One box cannot distinguish "the site is down" from "my
box's network is down", and that distinction is a real part of what the paid
product sells. Say so on the status page, not just in the README.
- On-call scheduling, escalation policies and phone-call alerts.
- Incident timelines and postmortem tooling.
### README must contain
- The single-region caveat, stated plainly.
- The warning that a monitor hosted on the same machine as the monitored service
reports nothing when it matters most · put it on a different box or a different
provider than the thing it watches.
===== ARCHITECTURE.md =====
# Architecture · Better Stack
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Runtime | Node 22 with node:http, node:sqlite and global fetch | the check is a fetch; the page is a few indexed queries |
| Database | SQLite in WAL mode | checks, incidents and monitors in one file |
| Alerts | One chat webhook | the channel you already watch |
| Hosting | A VPS on a different provider than the sites it watches | otherwise it goes down with them |
## Modules
Each module has one owner concern and a documented way to replace it.
| Module | Owns | How to replace it |
| --- | --- | --- |
| Checker | check(monitor) and its error taxonomy | Add a TCP or DNS checker as another function with the same result shape |
| Scheduler | the due loop and concurrency cap | Pure orchestration; unchanged by new checker types |
| State | transitions and incidents | Tunable by FAILURES_BEFORE_DOWN; testable with a fake clock |
| Status | the page and RSS | Any UI over checks and incidents |
## 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 · The chat webhook from the prerequisites.
- `ALERT_FORMAT` · required · discord, slack or telegram.
- `FAILURES_BEFORE_DOWN` · optional · Consecutive failures before a monitor is called down.
- `RETENTION_DAYS` · optional · Days of raw checks to keep. Incidents are kept forever.
- `SITE_URL` · required · Public base URL for the status page and RSS feed.
- `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 · Better Stack product build
- Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with node:http, node:sqlite and global fetch, SQLite in WAL mode, One chat webhook, A VPS on a different provider than the sites it watches.
- 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.
===== MILESTONES.md =====
# Delivery milestones · Better Stack
Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer.
## M1 · The check
One function from a monitor to a result, with a timeout that actually fires and distinct error kinds.
### Steps
1. Create the project and the three tables
monitors (id, name, url, interval_seconds, timeout_ms, expected_status, keyword, enabled, status, consecutive_failures, last_checked_at, last_change_at), checks (id, monitor_id, checked_at, ok, status_code, latency_ms, error), incidents (id, monitor_id, started_at, ended_at, cause). Index checks(monitor_id, checked_at).
Files: `server.mjs`, `db.mjs`, `check.mjs`
```sh
mkdir uptime && cd uptime && git init && npm init -y && npm pkg set type=module
mkdir data && cp .env.example .env
```
2. Write check(monitor)
fetch with an AbortController timeout, follow redirects, read at most 64 kB of body for the keyword, record latency. Return ok plus one of: dns, tls, timeout, status, keyword, as the error.
3. Add a CLI to run one check by hand
```sh
node scripts/check.mjs https://example.com 200 "Example Domain"
```
### Done when
- [ ] A good URL records ok with a sane latency
- [ ] A URL that never responds fails at the timeout, not later
- [ ] A 404 against expected 200 fails with the code recorded
- [ ] A keyword monitor fails when the word is absent from a 200 body
## M2 · Scheduler
Each monitor on its own interval, concurrently with a cap, and one bad monitor never stops the loop.
### Steps
1. Write the loop
Every 5 seconds find monitors due (last_checked_at plus interval before now), run up to 10 concurrently, catch every exception per monitor. On boot treat every monitor as due.
2. Store a checks row per run and update the monitor's last_checked_at
### Done when
- [ ] Monitors at 30, 60 and 300 seconds keep their cadence over ten minutes
- [ ] A monitor pointed at a hanging host does not delay the others
- [ ] A thrown exception inside one check leaves the loop running
## M3 · Transitions and incidents
Down only after N consecutive failures, up on the first success, one incident per outage.
### Steps
1. Implement the transition rule
Increment consecutive_failures on a failed check; at FAILURES_BEFORE_DOWN flip to down. Any success resets the counter and flips to up.
2. Open and close incidents on the transitions
Open a row on the flip to down with the first failure's error as cause; set ended_at on recovery. Incidents, not checks, are what people read later.
### Done when
- [ ] A single blip opens no incident
- [ ] Two consecutive failures open one
- [ ] Recovery closes it with a correct duration
- [ ] Flapping up/down/up/down produces one incident, not four
## M4 · Alerting
One message down, one up, retries that never stall the loop.
### Steps
1. Write the sender for ALERT_FORMAT
Down: monitor name and the error. Three retries with backoff, then record the failure and move on.
2. Send the recovery message with the outage duration
Duration comes from the incident row's started_at and ended_at.
### Done when
- [ ] Taking a site down produces exactly one message
- [ ] Keeping it down produces none
- [ ] Recovery produces exactly one with the duration
- [ ] A webhook URL that 500s does not stall checking
## M5 · Status page
A public page whose numbers can be argued with, and a feed people can subscribe to.
### Steps
1. Build /status
One row per monitor: dot, state, uptime over 24 h, 7 d and 30 d with the denominator printed (over 2,880 checks), a latency sparkline as inline SVG, recent incidents with durations. Meta refresh every 30 seconds.
2. Add /status.rss with one item per incident
Subscription without accounts.
3. Seed 90 days of checks and time the page
```sh
node scripts/seed.mjs 90
```
### Done when
- [ ] Percentages match a hand-written query
- [ ] The page renders with zero history on a fresh install
- [ ] It loads in under 200 ms with 90 days of checks
- [ ] The RSS feed validates
## M6 · Retention and deploy
Old checks pruned, incidents kept, live behind HTTPS on the other provider.
### Steps
1. Nightly prune of checks older than RETENTION_DAYS, never incidents
2. Add /healthz, systemd with restart-on-failure, Caddy, and the README
README: the single-region caveat stated plainly, the instruction to host on a different provider than the things watched, and the RSS URL.
Files: `deploy/uptime.service`, `Caddyfile`, `README.md`
### Done when
- [ ] Pruning does not distort the 30-day figure
- [ ] The service survives a reboot with state intact
- [ ] The status page states the single-region caveat
## M7 · Operate it like a product (production only)
Only for the product-builder path: know when the monitor 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 · Better Stack
## Backup
SQLite .backup nightly. Incidents are the history worth keeping.
## Restore
Copy back, start, confirm monitors resume and the incident list is intact.
Do a restore drill before the first real user, and write the date here when it passes.
## Monitoring
Watch the watcher: an external check from a third provider on /healthz.
## Incident checklist
If the monitor is down the sites are unwatched, not down. Restore, then review the gap by hand.
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 any monitored site
- [ ] Flap test produces one incident
- [ ] One restore drill performed and dated
- [ ] Status page loads under 200 ms with 90 days of data
## Launch constraint
Do not market omitted Better Stack 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/uptime.db
# Required · secret. The chat webhook from the prerequisites.
ALERT_WEBHOOK_URL=https://hooks.slack.com/services/...
# Required. discord, slack or telegram.
ALERT_FORMAT=slack
# Optional. Consecutive failures before a monitor is called down.
FAILURES_BEFORE_DOWN=2
# Optional. Days of raw checks to keep. Incidents are kept forever.
RETENTION_DAYS=90
# Required. Public base URL for the status page and RSS feed.
SITE_URL=https://status.yourdomain.com
# 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
# Better Stack · indie build
An uptime monitor you run yourself: a fetch on an interval with a real timeout, a state machine that only calls a site down after two consecutive failures, one chat message on down and one on recovery, and a public status page with uptime percentages, a latency sparkline and an RSS feed of incidents. Honest about the one thing a single box cannot do: tell your outage from your own network's.
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 with node:http, node:sqlite and global fetch | the check is a fetch; the page is a few indexed queries |
| Database | SQLite in WAL mode | checks, incidents and monitors in one file |
| Alerts | One chat webhook | the channel you already watch |
| Hosting | A VPS on a different provider than the sites it watches | otherwise it goes down with them |
## Before you start
Have every one of these ready. The plan assumes them from step one.
- [ ] **Node.js 22 or newer** · free
- Why: Everything in this build runs on it: the server, the scripts, the tests.
- Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards.
- Verify: node --version prints v22 or higher
- [ ] **A terminal and a code editor** · free
- Why: Every step below is a command you type or a file you edit.
- Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal.
- Verify: You can open a folder and run a command in its terminal
- [ ] **Git** · free
- Why: History for your code, and the way most hosts deploy.
- Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists.
- Verify: git --version prints a version
- [ ] **A chat webhook URL (Discord, Slack or Telegram)** · free
- Why: Alerts go to a chat channel you already watch. A webhook URL is the only credential this needs.
- Get it: Discord: Server settings > Integrations > Webhooks > New Webhook, copy the URL. Slack: create an app at api.slack.com/apps, enable Incoming Webhooks, add to a channel, copy the URL. Telegram: create a bot with @BotFather and use the bot token plus your chat id.
- Verify: curl -X POST -H 'Content-Type: application/json' -d '{"content":"test"}' <url> posts a message (Discord form; Slack uses a text field)
- [ ] **The URLs to watch, with what healthy means for each** · free
- Why: Each monitor needs a URL, an interval, an expected status and optionally a keyword the body must contain.
- Get it: List every site or endpoint. For each decide: check every 60 seconds or 300, expect 200, and a word that only appears when the page really works.
- [ ] **A small VPS on a different provider than your sites** · about $5 a month
- Why: A monitor hosted next to what it watches reports nothing when that host dies. Different provider, ideally a different region.
- Get it: Hetzner if your sites are elsewhere, or vice versa. Smallest Ubuntu 24.04 instance with SSH.
- [ ] **A domain or subdomain** (optional) · roughly $10 a year, or free on an existing domain
- Why: A status page address you can give people, e.g. status.yourdomain.com.
- 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 uptime && cd uptime && git init && npm init -y && npm pkg set type=module
mkdir data && cp .env.example .env
```
Then copy `.env.example` to `.env` and fill in the values it documents.
## Honest limits
This build deliberately does not replace:
- Multi-region probes. One box cannot tell an outage from its own bad network; the status page says so.
- Subscriber email notifications, incident templates, on-call.
- global multi-region probes
- on-call scheduling & escalation policies
- incident timelines and postmortem tooling
- phone-call alerts
If one of those is essential to you, that is the reason to keep paying for Better Stack, and the README should say so rather than pretend.# Build brief · Better Stack
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 an uptime monitor like UptimeRobot or Better Stack. Build it in phases,
in the order below. Do not write the whole service in one pass. Finish a phase,
run its "Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- Node 22 with `node:http`, `node:sqlite` and global `fetch`, or Python 3.12 with
stdlib plus `httpx`. Pick one, no web framework.
- One process: the checker loop and the status page share it.
- SQLite at a path from `.env`, WAL mode.
### Data model (create this before Phase 1)
- `monitors`: id, name, url, method, interval_seconds, timeout_ms,
expected_status, keyword (nullable), enabled, status ('unknown' | 'up' |
'down'), consecutive_failures, last_checked_at, last_change_at
- `checks`: id, monitor_id, checked_at, ok (bool), status_code, latency_ms, error
- `incidents`: id, monitor_id, started_at, ended_at, cause
Index `checks(monitor_id, checked_at)`. All timestamps are UTC epoch
milliseconds. `incidents` is a separate table from `checks` on purpose: uptime
percentage comes from checks, but the human question ("how long was it down, and
why") comes from incidents, and deriving that from raw checks at read time gets
slow and wrong at the edges.
### Phase 1 · The check itself
Build: one function that takes a monitor and returns a result. Set an explicit
timeout with `AbortController` · a hung TCP connection with no timeout is the
bug that makes a monitor silently stop monitoring. Follow redirects, cap the body
read (64KB is plenty for a keyword match), record latency, and treat DNS failure,
TLS failure, timeout and a wrong status code as distinct `error` values rather
than one generic failure.
Done when: checking a known-good URL records ok with a sane latency; a URL that
never responds fails at the timeout, not later; a 404 against an
`expected_status` of 200 fails with the code recorded; and a keyword monitor
fails when the keyword is absent from a 200 response.
Do not build yet: scheduling, alerts, the page.
### Phase 2 · Scheduler
Build: the loop that runs due monitors on their own intervals, concurrently but
with a cap (say 10 at once), so one slow host cannot delay every other check.
Never let one monitor's exception kill the loop. Recover cleanly from a restart ·
on boot, treat every monitor as due rather than waiting a full interval.
Done when: three monitors with 30s, 60s and 300s intervals each run at their own
cadence for ten minutes; one monitor pointed at a host that hangs does not delay
the others; and throwing an exception inside one check leaves the loop running.
Do not build yet: alerting.
### Phase 3 · State transitions and flap suppression
Build: the state machine. A monitor goes `down` only after N consecutive failures
(default 2, configurable per monitor) and returns to `up` on the first success.
Open an `incidents` row on the transition to down and close it on recovery,
recording the cause from the first failing check.
Done when: a single blip does not open an incident, two consecutive failures do,
the incident closes on recovery with a correct duration, and a monitor flapping
up/down/up/down produces one incident rather than four.
### Phase 4 · Alerting
Build: a webhook sender (Discord, Slack or Telegram, URL in `.env`) firing once
on down and once on recovery · never on every failed check. Include the monitor
name, the error, and the outage duration on recovery. Retry a failed delivery
three times with backoff, then record the error and continue.
Done when: taking a monitored service down produces exactly one message, keeping
it down produces none, recovery produces exactly one with a correct duration, and
a broken webhook URL does not stall the checker loop.
### Phase 5 · Status page
Build: a public `/status` · one row per monitor with a green/red dot, current
state, uptime percentage over 24h, 7d and 30d, a latency sparkline as inline SVG,
and the recent incident list with durations. Auto-refresh every 30 seconds via a
meta refresh or a tiny fetch, no framework. Compute uptime from `checks`, and
state the denominator on the page ("over 2,880 checks") so the number can be
argued with.
Done when: percentages match a hand-written SQL query, the page renders correctly
with zero history on a fresh install, and it loads in under 200ms with 90 days of
checks in the database.
### Phase 6 · Retention and deploy
Build: a nightly prune keeping 90 days of `checks` (keep `incidents` forever ·
they are small and they are the history you actually reread), a `/healthz`
endpoint, a systemd unit with restart-on-failure, and the README.
Done when: pruning does not distort the 30-day uptime figure, and the service
comes back with state intact after a reboot.
### Out of scope (and why)
- Multi-region probes. One box cannot distinguish "the site is down" from "my
box's network is down", and that distinction is a real part of what the paid
product sells. Say so on the status page, not just in the README.
- On-call scheduling, escalation policies and phone-call alerts.
- Incident timelines and postmortem tooling.
### README must contain
- The single-region caveat, stated plainly.
- The warning that a monitor hosted on the same machine as the monitored service
reports nothing when it matters most · put it on a different box or a different
provider than the thing it watches.# Agent instructions · Better Stack indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with node:http, node:sqlite and global fetch, SQLite in WAL mode, One chat webhook, A VPS on a different provider than the sites it watches. 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".
# Build plan · Better Stack An uptime monitor you run yourself: a fetch on an interval with a real timeout, a state machine that only calls a site down after two consecutive failures, one chat message on down and one on recovery, and a public status page with uptime percentages, a latency sparkline and an RSS feed of incidents. Honest about the one thing a single box cannot do: tell your outage from your own network's. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · The check One function from a monitor to a result, with a timeout that actually fires and distinct error kinds. ### Steps 1. Create the project and the three tables monitors (id, name, url, interval_seconds, timeout_ms, expected_status, keyword, enabled, status, consecutive_failures, last_checked_at, last_change_at), checks (id, monitor_id, checked_at, ok, status_code, latency_ms, error), incidents (id, monitor_id, started_at, ended_at, cause). Index checks(monitor_id, checked_at). Files: `server.mjs`, `db.mjs`, `check.mjs` ```sh mkdir uptime && cd uptime && git init && npm init -y && npm pkg set type=module mkdir data && cp .env.example .env ``` 2. Write check(monitor) fetch with an AbortController timeout, follow redirects, read at most 64 kB of body for the keyword, record latency. Return ok plus one of: dns, tls, timeout, status, keyword, as the error. 3. Add a CLI to run one check by hand ```sh node scripts/check.mjs https://example.com 200 "Example Domain" ``` ### Done when - [ ] A good URL records ok with a sane latency - [ ] A URL that never responds fails at the timeout, not later - [ ] A 404 against expected 200 fails with the code recorded - [ ] A keyword monitor fails when the word is absent from a 200 body ## Phase 2 · Scheduler Each monitor on its own interval, concurrently with a cap, and one bad monitor never stops the loop. ### Steps 1. Write the loop Every 5 seconds find monitors due (last_checked_at plus interval before now), run up to 10 concurrently, catch every exception per monitor. On boot treat every monitor as due. 2. Store a checks row per run and update the monitor's last_checked_at ### Done when - [ ] Monitors at 30, 60 and 300 seconds keep their cadence over ten minutes - [ ] A monitor pointed at a hanging host does not delay the others - [ ] A thrown exception inside one check leaves the loop running ## Phase 3 · Transitions and incidents Down only after N consecutive failures, up on the first success, one incident per outage. ### Steps 1. Implement the transition rule Increment consecutive_failures on a failed check; at FAILURES_BEFORE_DOWN flip to down. Any success resets the counter and flips to up. 2. Open and close incidents on the transitions Open a row on the flip to down with the first failure's error as cause; set ended_at on recovery. Incidents, not checks, are what people read later. ### Done when - [ ] A single blip opens no incident - [ ] Two consecutive failures open one - [ ] Recovery closes it with a correct duration - [ ] Flapping up/down/up/down produces one incident, not four ## Phase 4 · Alerting One message down, one up, retries that never stall the loop. ### Steps 1. Write the sender for ALERT_FORMAT Down: monitor name and the error. Three retries with backoff, then record the failure and move on. 2. Send the recovery message with the outage duration Duration comes from the incident row's started_at and ended_at. ### Done when - [ ] Taking a site down produces exactly one message - [ ] Keeping it down produces none - [ ] Recovery produces exactly one with the duration - [ ] A webhook URL that 500s does not stall checking ## Phase 5 · Status page A public page whose numbers can be argued with, and a feed people can subscribe to. ### Steps 1. Build /status One row per monitor: dot, state, uptime over 24 h, 7 d and 30 d with the denominator printed (over 2,880 checks), a latency sparkline as inline SVG, recent incidents with durations. Meta refresh every 30 seconds. 2. Add /status.rss with one item per incident Subscription without accounts. 3. Seed 90 days of checks and time the page ```sh node scripts/seed.mjs 90 ``` ### Done when - [ ] Percentages match a hand-written query - [ ] The page renders with zero history on a fresh install - [ ] It loads in under 200 ms with 90 days of checks - [ ] The RSS feed validates ## Phase 6 · Retention and deploy Old checks pruned, incidents kept, live behind HTTPS on the other provider. ### Steps 1. Nightly prune of checks older than RETENTION_DAYS, never incidents 2. Add /healthz, systemd with restart-on-failure, Caddy, and the README README: the single-region caveat stated plainly, the instruction to host on a different provider than the things watched, and the RSS URL. Files: `deploy/uptime.service`, `Caddyfile`, `README.md` ### Done when - [ ] Pruning does not distort the 30-day figure - [ ] The service survives a reboot with state intact - [ ] The status page states the single-region caveat ## Not in this build - Multi-region probes. One box cannot tell an outage from its own bad network; the status page says so. - Subscriber email notifications, incident templates, on-call. ## After v1, if you want it - A second probe location that must agree before an incident opens - Maintenance windows that suppress alerts
# 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/uptime.db # Required · secret. The chat webhook from the prerequisites. ALERT_WEBHOOK_URL=https://hooks.slack.com/services/... # Required. discord, slack or telegram. ALERT_FORMAT=slack # Optional. Consecutive failures before a monitor is called down. FAILURES_BEFORE_DOWN=2 # Optional. Days of raw checks to keep. Incidents are kept forever. RETENTION_DAYS=90 # Required. Public base URL for the status page and RSS feed. SITE_URL=https://status.yourdomain.com # 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
# Better Stack · product brief ## Problem A cron loop, a fetch, an alert webhook, and a status page. The $30/mo is for the dashboard gloss. ## Product outcome A status page you could show customers: honest percentages, incident history, a feed, and a monitor that is itself monitored from a third place. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Implement the core workflow described in ARCHITECTURE.md ## Explicit non-goals for v1 - Multi-region probes. One box cannot tell an outage from its own bad network; the status page says so. - Subscriber email notifications, incident templates, on-call. - global multi-region probes - on-call scheduling & escalation policies - incident timelines and postmortem tooling - phone-call alerts ## Success criteria - Runs on a different provider than any monitored site - Flap test produces one incident - One restore drill performed and dated - Status page loads under 200 ms with 90 days of data
# Build brief · Better Stack
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 an uptime monitor like UptimeRobot or Better Stack. Build it in phases,
in the order below. Do not write the whole service in one pass. Finish a phase,
run its "Done when" check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- Node 22 with `node:http`, `node:sqlite` and global `fetch`, or Python 3.12 with
stdlib plus `httpx`. Pick one, no web framework.
- One process: the checker loop and the status page share it.
- SQLite at a path from `.env`, WAL mode.
### Data model (create this before Phase 1)
- `monitors`: id, name, url, method, interval_seconds, timeout_ms,
expected_status, keyword (nullable), enabled, status ('unknown' | 'up' |
'down'), consecutive_failures, last_checked_at, last_change_at
- `checks`: id, monitor_id, checked_at, ok (bool), status_code, latency_ms, error
- `incidents`: id, monitor_id, started_at, ended_at, cause
Index `checks(monitor_id, checked_at)`. All timestamps are UTC epoch
milliseconds. `incidents` is a separate table from `checks` on purpose: uptime
percentage comes from checks, but the human question ("how long was it down, and
why") comes from incidents, and deriving that from raw checks at read time gets
slow and wrong at the edges.
### Phase 1 · The check itself
Build: one function that takes a monitor and returns a result. Set an explicit
timeout with `AbortController` · a hung TCP connection with no timeout is the
bug that makes a monitor silently stop monitoring. Follow redirects, cap the body
read (64KB is plenty for a keyword match), record latency, and treat DNS failure,
TLS failure, timeout and a wrong status code as distinct `error` values rather
than one generic failure.
Done when: checking a known-good URL records ok with a sane latency; a URL that
never responds fails at the timeout, not later; a 404 against an
`expected_status` of 200 fails with the code recorded; and a keyword monitor
fails when the keyword is absent from a 200 response.
Do not build yet: scheduling, alerts, the page.
### Phase 2 · Scheduler
Build: the loop that runs due monitors on their own intervals, concurrently but
with a cap (say 10 at once), so one slow host cannot delay every other check.
Never let one monitor's exception kill the loop. Recover cleanly from a restart ·
on boot, treat every monitor as due rather than waiting a full interval.
Done when: three monitors with 30s, 60s and 300s intervals each run at their own
cadence for ten minutes; one monitor pointed at a host that hangs does not delay
the others; and throwing an exception inside one check leaves the loop running.
Do not build yet: alerting.
### Phase 3 · State transitions and flap suppression
Build: the state machine. A monitor goes `down` only after N consecutive failures
(default 2, configurable per monitor) and returns to `up` on the first success.
Open an `incidents` row on the transition to down and close it on recovery,
recording the cause from the first failing check.
Done when: a single blip does not open an incident, two consecutive failures do,
the incident closes on recovery with a correct duration, and a monitor flapping
up/down/up/down produces one incident rather than four.
### Phase 4 · Alerting
Build: a webhook sender (Discord, Slack or Telegram, URL in `.env`) firing once
on down and once on recovery · never on every failed check. Include the monitor
name, the error, and the outage duration on recovery. Retry a failed delivery
three times with backoff, then record the error and continue.
Done when: taking a monitored service down produces exactly one message, keeping
it down produces none, recovery produces exactly one with a correct duration, and
a broken webhook URL does not stall the checker loop.
### Phase 5 · Status page
Build: a public `/status` · one row per monitor with a green/red dot, current
state, uptime percentage over 24h, 7d and 30d, a latency sparkline as inline SVG,
and the recent incident list with durations. Auto-refresh every 30 seconds via a
meta refresh or a tiny fetch, no framework. Compute uptime from `checks`, and
state the denominator on the page ("over 2,880 checks") so the number can be
argued with.
Done when: percentages match a hand-written SQL query, the page renders correctly
with zero history on a fresh install, and it loads in under 200ms with 90 days of
checks in the database.
### Phase 6 · Retention and deploy
Build: a nightly prune keeping 90 days of `checks` (keep `incidents` forever ·
they are small and they are the history you actually reread), a `/healthz`
endpoint, a systemd unit with restart-on-failure, and the README.
Done when: pruning does not distort the 30-day uptime figure, and the service
comes back with state intact after a reboot.
### Out of scope (and why)
- Multi-region probes. One box cannot distinguish "the site is down" from "my
box's network is down", and that distinction is a real part of what the paid
product sells. Say so on the status page, not just in the README.
- On-call scheduling, escalation policies and phone-call alerts.
- Incident timelines and postmortem tooling.
### README must contain
- The single-region caveat, stated plainly.
- The warning that a monitor hosted on the same machine as the monitored service
reports nothing when it matters most · put it on a different box or a different
provider than the thing it watches.# Architecture · Better Stack ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with node:http, node:sqlite and global fetch | the check is a fetch; the page is a few indexed queries | | Database | SQLite in WAL mode | checks, incidents and monitors in one file | | Alerts | One chat webhook | the channel you already watch | | Hosting | A VPS on a different provider than the sites it watches | otherwise it goes down with them | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Checker | check(monitor) and its error taxonomy | Add a TCP or DNS checker as another function with the same result shape | | Scheduler | the due loop and concurrency cap | Pure orchestration; unchanged by new checker types | | State | transitions and incidents | Tunable by FAILURES_BEFORE_DOWN; testable with a fake clock | | Status | the page and RSS | Any UI over checks and incidents | ## 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 · The chat webhook from the prerequisites. - `ALERT_FORMAT` · required · discord, slack or telegram. - `FAILURES_BEFORE_DOWN` · optional · Consecutive failures before a monitor is called down. - `RETENTION_DAYS` · optional · Days of raw checks to keep. Incidents are kept forever. - `SITE_URL` · required · Public base URL for the status page and RSS feed. - `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 · Better Stack product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with node:http, node:sqlite and global fetch, SQLite in WAL mode, One chat webhook, A VPS on a different provider than the sites it watches. - 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.
# Delivery milestones · Better Stack Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · The check One function from a monitor to a result, with a timeout that actually fires and distinct error kinds. ### Steps 1. Create the project and the three tables monitors (id, name, url, interval_seconds, timeout_ms, expected_status, keyword, enabled, status, consecutive_failures, last_checked_at, last_change_at), checks (id, monitor_id, checked_at, ok, status_code, latency_ms, error), incidents (id, monitor_id, started_at, ended_at, cause). Index checks(monitor_id, checked_at). Files: `server.mjs`, `db.mjs`, `check.mjs` ```sh mkdir uptime && cd uptime && git init && npm init -y && npm pkg set type=module mkdir data && cp .env.example .env ``` 2. Write check(monitor) fetch with an AbortController timeout, follow redirects, read at most 64 kB of body for the keyword, record latency. Return ok plus one of: dns, tls, timeout, status, keyword, as the error. 3. Add a CLI to run one check by hand ```sh node scripts/check.mjs https://example.com 200 "Example Domain" ``` ### Done when - [ ] A good URL records ok with a sane latency - [ ] A URL that never responds fails at the timeout, not later - [ ] A 404 against expected 200 fails with the code recorded - [ ] A keyword monitor fails when the word is absent from a 200 body ## M2 · Scheduler Each monitor on its own interval, concurrently with a cap, and one bad monitor never stops the loop. ### Steps 1. Write the loop Every 5 seconds find monitors due (last_checked_at plus interval before now), run up to 10 concurrently, catch every exception per monitor. On boot treat every monitor as due. 2. Store a checks row per run and update the monitor's last_checked_at ### Done when - [ ] Monitors at 30, 60 and 300 seconds keep their cadence over ten minutes - [ ] A monitor pointed at a hanging host does not delay the others - [ ] A thrown exception inside one check leaves the loop running ## M3 · Transitions and incidents Down only after N consecutive failures, up on the first success, one incident per outage. ### Steps 1. Implement the transition rule Increment consecutive_failures on a failed check; at FAILURES_BEFORE_DOWN flip to down. Any success resets the counter and flips to up. 2. Open and close incidents on the transitions Open a row on the flip to down with the first failure's error as cause; set ended_at on recovery. Incidents, not checks, are what people read later. ### Done when - [ ] A single blip opens no incident - [ ] Two consecutive failures open one - [ ] Recovery closes it with a correct duration - [ ] Flapping up/down/up/down produces one incident, not four ## M4 · Alerting One message down, one up, retries that never stall the loop. ### Steps 1. Write the sender for ALERT_FORMAT Down: monitor name and the error. Three retries with backoff, then record the failure and move on. 2. Send the recovery message with the outage duration Duration comes from the incident row's started_at and ended_at. ### Done when - [ ] Taking a site down produces exactly one message - [ ] Keeping it down produces none - [ ] Recovery produces exactly one with the duration - [ ] A webhook URL that 500s does not stall checking ## M5 · Status page A public page whose numbers can be argued with, and a feed people can subscribe to. ### Steps 1. Build /status One row per monitor: dot, state, uptime over 24 h, 7 d and 30 d with the denominator printed (over 2,880 checks), a latency sparkline as inline SVG, recent incidents with durations. Meta refresh every 30 seconds. 2. Add /status.rss with one item per incident Subscription without accounts. 3. Seed 90 days of checks and time the page ```sh node scripts/seed.mjs 90 ``` ### Done when - [ ] Percentages match a hand-written query - [ ] The page renders with zero history on a fresh install - [ ] It loads in under 200 ms with 90 days of checks - [ ] The RSS feed validates ## M6 · Retention and deploy Old checks pruned, incidents kept, live behind HTTPS on the other provider. ### Steps 1. Nightly prune of checks older than RETENTION_DAYS, never incidents 2. Add /healthz, systemd with restart-on-failure, Caddy, and the README README: the single-region caveat stated plainly, the instruction to host on a different provider than the things watched, and the RSS URL. Files: `deploy/uptime.service`, `Caddyfile`, `README.md` ### Done when - [ ] Pruning does not distort the 30-day figure - [ ] The service survives a reboot with state intact - [ ] The status page states the single-region caveat ## M7 · Operate it like a product (production only) Only for the product-builder path: know when the monitor 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 · Better Stack ## Backup SQLite .backup nightly. Incidents are the history worth keeping. ## Restore Copy back, start, confirm monitors resume and the incident list is intact. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Watch the watcher: an external check from a third provider on /healthz. ## Incident checklist If the monitor is down the sites are unwatched, not down. Restore, then review the gap by hand. 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 any monitored site - [ ] Flap test produces one incident - [ ] One restore drill performed and dated - [ ] Status page loads under 200 ms with 90 days of data ## Launch constraint Do not market omitted Better Stack 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/uptime.db # Required · secret. The chat webhook from the prerequisites. ALERT_WEBHOOK_URL=https://hooks.slack.com/services/... # Required. discord, slack or telegram. ALERT_FORMAT=slack # Optional. Consecutive failures before a monitor is called down. FAILURES_BEFORE_DOWN=2 # Optional. Days of raw checks to keep. Incidents are kept forever. RETENTION_DAYS=90 # Required. Public base URL for the status page and RSS feed. SITE_URL=https://status.yourdomain.com # 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
xglobal multi-region probes
xon-call scheduling & escalation policies
xincident timelines and postmortem tooling
xphone-call alerts
Don't feel like building it? These folks already made it free.
all 5 free alternatives to Better Stack →· no votes, no pay-to-list · just what's real
Better Stack pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free for personal projects | $0/workspace | $0/workspace | 10 monitors, 10 heartbeats, 1 status page; 3-minute checks; 1,000 status-page subscribers; 100,000 exceptions/month; 5,000 session replays; 3 GB each of logs, traces and web events retained 3 days; 30 GB metrics |
| responder | $34/user | $29/user | 1 responder license; unlimited phone-call and SMS alerts; 10 monitors, 10 heartbeats and 1 status page included at workspace level |
| enterprise | custom | — | Custom quote; custom limits and enterprise controls |
free tier10 monitors + 10 heartbeats + 1 status page with 3-minute checks; 1,000 subscribers; 100,000 exceptions/month; 5,000 replays; 3 GB logs + 3 GB traces + 3 GB web events retained 3 days; 30 GB metrics
billingmonthly + annual (about 2 months free)
hidden costsBase includes only 10 monitors/10 heartbeats/1 status page. Add 50 monitors: $25/mo or $21/mo annual; add 10 heartbeats: $20/$17; each additional public status page: $15/$12; 1,000 extra subscribers: $40/mo; Playwright: $1 per 100 minutes; AI SRE: $5/million tokens; Slack/Teams workflows: $9/responder/mo; advanced status-page and SSO options can cost $42-$250 per page/user/month.
verified 2026-08-12 · source ↗
Vibecode Better Stack / UptimeRobot paid
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Better Stack / UptimeRobot paid replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Better Stack / UptimeRobot paid cost?
Better Stack / UptimeRobot paid costs about $34/month (Responder, checked 2026-08-12), which is $408 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing Better Stack / UptimeRobot paid?
Honestly: global multi-region probes; on-call scheduling & escalation policies; incident timelines and postmortem tooling; phone-call alerts. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Better Stack / UptimeRobot paid?
Yes: Uptime Kuma (A cheerful dashboard for asking URLs whether they are dead yet.) Gatus (A YAML file, a status board and enough protocol checks to annoy most outages.) OpenStatus (Uptime checks and a status page, with YAML for the suspicious.) All 5 curated free alternatives are at vibecodeit.com/uptime/alternatives. The prompt is for when you want it exactly your way.