How to build an uptime monitor

How to build an uptime monitor with cron HTTP checks and one email when two pings fail. Follow the prompts, check each step, then skip global probes.

  • guide
  • vibe-coding
  • uptimerobot

You can learn how to build an uptime monitor as cron HTTP checks and one email when two pings fail. UptimeRobot is that product. The catalog line is “Ping the thing. Tell me when the thing stops pinging.”

You pay $24–$69 a month. That is $288–$828 a year. The verdict is “Cron jobs can do a lot”. The first estimate is 6 hours: cron http checks, email alerts.

There is no standalone teardown yet. The hosting post already uses this card as the cron example: ping https://flaviocopes.com/health, store the result, mail Ada when two checks fail. This guide is the walk. Prompts in order, then what to look at.

The audience is a developer watching fewer than 25 websites and APIs. If you need probes in five regions, or a phone call at 3am, keep paying. The same v1 also replaces Checkly, Better Stack, and Pingdom.

Cronitor is the heartbeat cousin, $10–$49 a month, about 6 hours. Your job pings it. This app pings your URL. A public board customers can read is the status page guide. Sentry is $26–$89 a month when the site is up and the code is not.

How to build an uptime monitor without a probe network

The UptimeRobot page splits the work into three rows.

The first row is the one you want. An HTTP monitor. A cron that fetches it. An email after two failures in a row. That is the 6-hour v1.

The second row is 2 days. A richer threshold, incident timelines, latency charts, uptime percentages. Do that after Ada has received one real alert.

The third row is 1 month and up. Global probe regions, phone alerts, synthetic browser tests. Skip it.

If you tell an agent to “clone UptimeRobot”, it aims at the third row. Pin every prompt to the first one.

Here is the shape of the data. Nothing else has to exist at 6 hours.

const monitor = {
  id: 'mon-health',
  name: 'flaviocopes.com /health',
  method: 'GET',
  url: 'https://flaviocopes.com/health',
  expectedStatus: 200,
  intervalMinutes: 5,
  timeoutMs: 5000,
  failureThreshold: 2,
  consecutiveFailures: 0,
  streakStartedAt: null,
  state: 'up',
}

const checkResult = {
  id: 'chk-health-0900',
  monitorId: 'mon-health',
  slot: '2026-10-28T09:00:00.000Z',
  status: 200,
  latencyMs: 142,
  ok: true,
  reason: null,
}

const incident = {
  id: 'inc-health-1',
  monitorId: 'mon-health',
  openedAt: '2026-10-28T09:05:00.000Z',
  closedAt: null,
}

const alertChannel = {
  id: 'alert-ada',
  monitorId: 'mon-health',
  kind: 'email',
  address: '[email protected]',
}

A monitor, one result per scheduled slot, one open incident, one email address. The card lists those four entities. Latency charts and a 30-day percentage wait for the 2-day row.

monitors.url is unique. check_results is unique on (monitor_id, slot). incidents allows one open row per monitor.

SAVE THE URL GET /health, expect 200 Reject private targets CRON THE CHECK One result per 5-minute slot Timeout 5 seconds, 8 KB max OPEN ONE INCIDENT Two failures in a row A success resets the streak MAIL ADA ONCE Failure, then recovery
Save a public URL. Cron it. Two misses mail Ada. The next 200 closes it.

Pick the stack you already write

Every product prompt ships in two flavors: Node.js plus Astro, and TanStack Start. The capability line for UptimeRobot is Workers, D1, and Astro.

This site is an Astro app on Cloudflare, so that is the path I default to. Use TanStack Start if React is your weekday language. The Worker still has to run the cron. A dashboard in React does not replace scheduled().

Open Build your own UptimeRobot. The guardrail is the useful sentence: restrict private-network targets to prevent SSRF, and use bounded timeouts and response sizes. The non-goal sits next to it. No global probe regions, phone calls, synthetic browser tests, or infrastructure monitoring.

The stack post is which tab you copy. The prompt post is how you pin the first estimate. The scope post is why the probe mesh waits. Pick one stack and stay on it.

Prompt 1. Scaffold Pingthing and stop

Copy the Astro prompt from the product page. Paste it into your coding tool. Put this block above it.

Ship the 6-hour row only. Cron HTTP
checks, email alerts.

Project name: Pingthing.
Public URL is https://ping.flaviocopes.com.
Owner is Flavio. Ada gets the mail
at [email protected].

First monitor: GET
https://flaviocopes.com/health
Expected status 200.
Interval 5 minutes.
Timeout 5 seconds.
Failure threshold 2.

Do not add latency charts, 30-day
uptime percentages, or a second
alert channel yet.
Do not add global probe regions,
phone calls, synthetic browser
tests, or server agents.

When the plan is done, stop and wait.
Do not write implementation code
until I say go.

The official prompt already asks for a short technical plan first: routes, tables, authorization, jobs. Let it produce that plan. Read it.

You want pages like these:

  • /monitors for up, down, paused, and the add action
  • /monitors/mon-health for the editor and a test check
  • /monitors/mon-health/log for recent results
  • /incidents for open and closed rows

You want tables monitors, check_results, incidents, alert_channels. probe_regions should not appear. phone_numbers should not appear.

If the plan includes Playwright, Twilio, or a regions table, send it back. Paste the non-goal again.

When the plan is narrow, say go. Let it scaffold the app, the migrations, and empty screens.

What to check after the scaffold

Start the app. Sign in as Flavio. You should see an empty monitor list and an add action.

Open the migrations. check_results has a unique (monitor_id, slot). incidents has a partial unique index so only one row per monitor can be open. There is no region column.

If the README is a placeholder, stop and make the agent write a real one before you add logic. The README has to name the cron, the email adapter, and the private-range reject list.

Prompt 2. Save a public URL and reject the rest

The first mini-prompt on the card is “The pinger that can’t be aimed inward”. An uptime monitor fetches a URL the owner typed. That is also how you accidentally fetch 127.0.0.1 or a cloud metadata address.

Resolve the hostname before every check, not only when the form saves. A name that was public on Tuesday can point at a private address on Wednesday.

Build the monitor editor and the
URL safety layer for Pingthing.

The owner saves name, method, URL,
expected status, interval, timeout,
and failure threshold. Methods in
v1: GET and HEAD. Interval is 5
minutes tonight. Timeout is 1 to
10 seconds. Threshold is 2.

Accept only http and https.
Before save and before every
check, resolve the hostname.
Reject loopback, RFC 1918,
link-local, and
169.254.169.254. Connect to the
vetted IP and send the original
Host header.

Follow at most 3 redirects. Each
hop must pass the same checks.
A public URL that hops to a
private address fails the check
with reason redirect-private.

Bound the read to 8 KB. Record
status, latency, and a short
reason. Do not store the body.

A Test check button runs the
same path once, without opening
an incident.

Seed mon-health as above.
Alert channel [email protected].

Done when I can save that
monitor, a test against
https://flaviocopes.com/health
returns 200, and saving
http://127.0.0.1/ or
http://169.254.169.254/ is
rejected on the form.
const BLOCKED = [
  '127.0.0.0/8',
  '10.0.0.0/8',
  '172.16.0.0/12',
  '192.168.0.0/16',
  '169.254.0.0/16',
]

function isPublicTarget(url, resolvedIp) {
  const parsed = new URL(url)
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
    return false
  }
  return !BLOCKED.some((cidr) => inRange(resolvedIp, cidr))
}

isPublicTarget('https://flaviocopes.com/health', '1.1.1.1')
//true

isPublicTarget('http://127.0.0.1/', '127.0.0.1')
//false

1.1.1.1 is public. 10.0.0.4 is not. The form and the cron share this function.

What to check after the URL saves

Create mon-health. Refresh. The row says up, last check empty.

Click Test. You get a 200 and a latency. No incident row.

Try http://127.0.0.1/. The form refuses. Try http://169.254.169.254/. Same refusal. Try http://10.0.0.4/. Same.

If the agent stored a response body, delete that column. Status and reason are enough.

Prompt 3. Cron the check once per slot

The third mini-prompt is “Cron jobs that overlap without lying”. Cloudflare Cron Triggers will overlap when a slow check hangs, and they will retry. Two workers must not write two results for the same five-minute slot.

Build the scheduled checker for
Pingthing.

wrangler.jsonc has a Cron Trigger
every 5 minutes. scheduled() walks
enabled monitors whose interval
has elapsed.

Each check has a deterministic
id: monitor id plus the UTC slot
it represents. mon-health at
2026-10-28T09:00:00.000Z is
mon-health:2026-10-28T09:00:00.000Z.

Insert the check_results row with
that key first. A second worker
that hits the unique constraint
discards and moves on.

DRY_RUN=1: do not fetch the live
URL. Use a fixture file:
- 200 for the first slot
- 500 for the next two
- 200 after that

Keep runtime under the interval.
If a monitor's previous slot is
still running, skip this one.
A missed slot is a gap, not a
fake result.

Do not open incidents yet.
Do not send email yet.

Done when I trigger scheduled()
twice for the 09:00 slot and
check_results has one row for
mon-health:2026-10-28T09:00:00.000Z.
export default {
  async scheduled(controller, env) {
    const slot = slotFor(controller.scheduledTime, 5)
    const monitors = await env.db.listEnabled()

    for (const monitor of monitors) {
      const key = `${monitor.id}:${slot}`
      const claimed = await env.db.claimSlot(key, monitor.id, slot)
      if (!claimed) continue
      await runCheck(monitor, env, key)
    }
  },
}

function slotFor(scheduledTime, intervalMinutes) {
  const ms = intervalMinutes * 60 * 1000
  const rounded = Math.floor(scheduledTime / ms) * ms
  return new Date(rounded).toISOString()
}

slotFor(Date.parse('2026-10-28T09:03:12.000Z'), 5)
//'2026-10-28T09:00:00.000Z'

claimSlot is an insert. The unique key is the lock. The loser does not fetch.

async function runCheck(monitor, env, key) {
  const target = await resolvePublic(monitor.url)
  if (!target.ok) {
    return env.db.finishResult(key, {
      ok: false,
      reason: target.reason,
    })
  }

  const started = Date.now()
  try {
    const response = await fetch(target.href, {
      method: monitor.method,
      headers: { host: target.host },
      signal: AbortSignal.timeout(monitor.timeoutMs),
      redirect: 'manual',
    })
    const ok = response.status === monitor.expectedStatus
    return env.db.finishResult(key, {
      ok,
      status: response.status,
      latencyMs: Date.now() - started,
      reason: ok ? null : `expected ${monitor.expectedStatus}, got ${response.status}`,
    })
  } catch (error) {
    return env.db.finishResult(key, {
      ok: false,
      latencyMs: Date.now() - started,
      reason: error.name === 'TimeoutError' ? 'timeout' : 'network',
    })
  }
}

A timeout is a failed check with reason timeout. Bound the body read even when you throw the bytes away, so a 200 with a 40 MB homepage cannot stall the next monitor.

What to check after the cron fires

Set DRY_RUN=1. Trigger scheduled() twice for 2026-10-28T09:00:00.000Z.

/monitors/mon-health/log has one row. Status 200. Same slot.

Trigger the next two slots as 500s. The log has three rows. Still no email. That wait is prompt 4.

If the agent wrote two rows for 09:00, the unique key is missing. Send prompt 3 back.

Prompt 4. Mail Ada after two failures

The second mini-prompt is “Three strikes, one incident”. Tonight the threshold is 2, because that is the first-row story: two pings fail, then Ada hears about it.

A single 500 is a blip, so two in a row is the incident. A 200 in the middle resets the streak and writes no incident.

Build incidents and email for
Pingthing.

failureThreshold is 2 on
mon-health. A failed check
increments consecutiveFailures.
A success sets it to 0.

The check that reaches 2 opens
exactly one incident. Start time
is the first failure in the
streak, not the confirming one.
Send one failure email to
[email protected] with URL,
status, reason, and the two
slot times.

Further failures attach to that
open incident. They do not open
a sibling and they do not mail
again.

The next successful check closes
the incident, stores duration,
sends one recovery email, and
sets state up.

Key each alert on
incident id plus opened or
closed. A retried cron cannot
send the failure mail twice.

Local email adapter prints the
message. .env.example lists
EMAIL_FROM and EMAIL_TO.
No live provider in v1.

Do not add Slack.
Do not add SMS.
Do not draw a latency chart.

Done when fail, fail creates one
incident and one printed failure
mail, a third fail prints
nothing, and the next 200 prints
one recovery mail and closes
inc-health-1.
function nextState(monitor, result) {
  if (result.ok) {
    return {
      consecutiveFailures: 0,
      streakStartedAt: null,
      state: 'up',
      closeIncident: monitor.state === 'down',
    }
  }

  const consecutiveFailures = monitor.consecutiveFailures + 1
  return {
    consecutiveFailures,
    streakStartedAt: monitor.streakStartedAt ?? result.slot,
    state: consecutiveFailures >= monitor.failureThreshold ? 'down' : monitor.state,
    openIncident: consecutiveFailures === monitor.failureThreshold,
  }
}

nextState(
  { consecutiveFailures: 0, failureThreshold: 2, state: 'up', streakStartedAt: null },
  { ok: false, slot: '2026-10-28T09:05:00.000Z' },
)
//{ consecutiveFailures: 1, state: 'up', openIncident: false }

nextState(
  { consecutiveFailures: 1, failureThreshold: 2, state: 'up', streakStartedAt: '2026-10-28T09:05:00.000Z' },
  { ok: false, slot: '2026-10-28T09:10:00.000Z' },
)
//{ consecutiveFailures: 2, state: 'down', openIncident: true }

Open and mail in the same transaction as the state change. If you commit down and then fail to insert the incident, /monitors shows a red row with no story.

async function applyCheck(db, mail, monitor, result) {
  const next = nextState(monitor, result)

  await db.transaction(async (tx) => {
    await tx.saveMonitor(monitor.id, next)

    if (next.openIncident) {
      const incident = await tx.openIncident({
        id: 'inc-health-1',
        monitorId: monitor.id,
        openedAt: monitor.streakStartedAt,
      })
      await mail.sendOnce(`${incident.id}:opened`, {
        to: '[email protected]',
        subject: 'DOWN flaviocopes.com /health',
        body: result.reason,
      })
    }

    if (next.closeIncident) {
      const incident = await tx.closeIncident(monitor.id, result.checkedAt)
      await mail.sendOnce(`${incident.id}:closed`, {
        to: '[email protected]',
        subject: 'UP flaviocopes.com /health',
        body: `down ${incident.durationMs}ms`,
      })
    }
  })
}

sendOnce uses the transition key. A Worker that dies after SMTP and retries the slot still has one failure mail.

What to check after Ada gets mail

Replay the fixture: 200, 500, 500, 500, 200.

The log has five rows. One incident. It opened on the first 500, confirmed on the second. The third 500 added a confirmation, not a second incident.

The terminal printed one DOWN and one UP. Duration is from the first 500 to the closing 200.

Replay the second 500’s cron. Still one DOWN line.

A 500 then a 200 creates no incident and no mail. The streak died at 1.

If /monitors says down and /incidents is empty, the writes split. Send prompt 4 back.

Failures the agent ships anyway

A dashboard with no cron

Saving a URL is a bookmarks app. The capability line is Workers and Cloudflare Cron Triggers. If wrangler.jsonc has no trigger, you built half the product. The hosting post is the binding, then the cron, then the domain.

Email on the first 500

One timeout during a deploy is noise. The first estimate still wants an email. Send it after two pings, not one.

A second incident for the same outage

Overlapping crons both see consecutiveFailures === 2 if the unique open-incident constraint is missing. Add that unique index before you test the mail.

Fetching whatever the form accepted

http://127.0.0.1/ and a metadata address have to fail at save and at check time. The guardrail is that sentence. Resolve again before every fetch.

A probe in Frankfurt and one in Sydney

That is the 1-month row. One Worker region is the 6-hour v1. If PROBE_REGIONS appears in .env.example, delete it.

A public status note is a different job. That is Statuspage, $29–$399 a month, about 3 hours. I wrote the teardown as Build a Statuspage alternative. You write that incident by hand.

A dead cron of your own is Cronitor. The job pings a secret URL. This app is the other direction.

If the rent is “the page loaded and then the JavaScript died”, that is Sentry, about 3 days. The Sentry walkthrough is that post.

Deploy the pinger

The product pages put hosting this way: a focused replacement costs your build time plus close-to-zero hosting.

Pingthing wants Workers, D1, and Astro. Put it on a subdomain you already own, ping.flaviocopes.com. D1 holds the tables. The Cron Trigger is the checker. Email is the adapter with a local print path.

The hosting post is that path: the adapter, then the bindings, then the custom domain. UptimeRobot is the ping example on that page.

Attach the domain on the Worker. Add the five-minute cron in wrangler.jsonc. Point EMAIL_TO at an inbox you read.

Ship with mon-health enabled. Break /health on a staging host you own, or point a second monitor at a URL you can take down. Wait for two slots. Confirm Ada got one DOWN. Bring it back. Confirm one UP. Confirm a third failed slot did not mail again.

Take a D1 backup before you delete the paid account. The check log is the history you will miss, so treat that backup like a password.

Use it on a real URL for a week, not as a demo. If you miss a latency chart or a 30-day percentage, those are the 2-day row and the other mini-prompts on the card. Add them after Ada has trusted one alert.

Then cancel UptimeRobot, or Checkly, whichever one you pay for.

What to skip on purpose

Leave out global probe regions, phone calls, synthetic browser tests, and anything that looks like host or agent monitoring.

6-HOUR V1 One public HTTP monitor Cron checks every 5 minutes Email after two failures SKIP THIS HALF Global probe regions Phone alerts Synthetic browser tests
Ship the cron and one inbox. Leave the probe mesh on the skip row.

A latency chart comes after you have a week of check_results. Uptime math that admits gaps is the fourth mini-prompt on the card. I would not build a headless Chrome check.

A queue that publishes later is a different cron. That is the Buffer guide. Same trigger shape, different job.

More writeups land on the guides index. The audit is the catalog if you want a smaller first ship.

Start now

Go to Build your own UptimeRobot. Copy the Astro prompt, or TanStack Start if that is what you write.

Tell the agent the first estimate only: cron http checks, email alerts. Six hours.

Point it at a URL you can break. Wait for two slots. If Ada gets one mail, you can cancel.