You Can't Monitor Your Cloudflare App From Cloudflare

Our app runs on Cloudflare — Workers for the API, Pages for the marketing site and the dashboard. It exposes the two endpoints you would expect: /healthz for liveness and /readyz for "every downstream binding I need is reachable." Both were green. And for a while that felt like enough.

It isn't, and the reason is a single sentence: a health check that runs on the same platform as the thing it checks goes blind exactly when you most need it to work.

If a Cloudflare Worker (or a GitHub Action pinned to the same edge, or anything else that resolves and routes through the provider you're checking) is the thing polling /healthz, then a Cloudflare account-level or edge-level outage takes down the monitor and the app together. The dashboard stays green — not because the app is up, but because nothing was able to observe that it went down. You find out from a customer. That is the worst possible discovery path for an outage.

This is a correlated-failure problem, and it is easy to miss because on any ordinary day the same-platform check works perfectly. It only fails on the day that matters.


The rule: your watchdog must fail independently from your app

The fix is not a better health endpoint. /healthz and /readyz were already correct. The fix is where the poll originates. An external uptime check is only worth having if the infrastructure running it fails independently from the infrastructure running your app.

For us that means: do not poll Cloudflare from Cloudflare. Poll it from something that has no shared blast radius with Cloudflare.

The obvious answer is a third-party monitor — UptimeRobot, StatusCake, Better Uptime. They are good, and if you have one, keep it. But every one of them needs an account, email verification, and a free-tier ceiling, and we were trying to close this gap on a $0 bootstrap budget without adding another vendor. So we asked a cheaper question: do we already run anything that fails independently from Cloudflare?

We did. Our database is Supabase, which runs on AWS. A Cloudflare outage and an AWS outage are about as uncorrelated as two clouds get. And Postgres can make outbound HTTP requests.

The $0 version: pg_cron + the http extension

Supabase ships two extensions that, together, are a complete uptime poller:

  • pg_cron — schedule a SQL statement on a cron expression, in-database.
  • http — synchronous outbound HTTP from a PL/pgSQL function.

So the watchdog is one function that GETs each URL, updates a little rolling state, and posts to a Discord webhook when something has been down long enough to be real. Roughly forty lines. The core of the loop:

-- bound each request so a hung endpoint can't stall the cron worker
perform extensions.http_set_curlopt('CURLOPT_TIMEOUT_MS', '5000');
perform extensions.http_set_curlopt('CURLOPT_CONNECTTIMEOUT_MS', '3000');

for t in select * from external_uptime_monitors where enabled loop
  begin
    resp := extensions.http_get(t.url);
    v_status := resp.status;
    v_ok := v_status between t.expect_status_min and t.expect_status_max;
  exception when others then
    v_ok := false;                       -- connection refused, DNS fail, timeout
    v_err := left(sqlerrm, 300);
  end;

  update external_uptime_monitors set
    last_status          = v_status,
    last_checked_at      = now(),
    consecutive_failures = case when v_ok then 0 else consecutive_failures + 1 end
  where url = t.url
  returning * into st;

  -- ...alert on a SUSTAINED down + on recovery (below)
end loop;

Then schedule it:

select cron.schedule(
  'external-uptime',
  '*/5 * * * *',
  'select run_external_uptime_check();'
);

That's it. Every five minutes, from AWS, something that is not Cloudflare checks whether Cloudflare is still serving your app.

Alert on sustained state, not on a single blip

The one design decision that matters more than the transport: do not alert on the first failed poll. A single failed GET from a database backend is as likely to be a transient network hiccup on the poller's side as a real outage. If you page on it, you train yourself to ignore the pager.

So the state machine tracks consecutive_failures and only fires once a monitor has failed a threshold number of checks in a row (we default to 2 — about ten minutes at a five-minute cadence). It fires exactly one "DOWN" alert on the transition, re-alerts on a slow interval while it stays down (so a multi-hour outage nudges you again without spamming), and fires one "RECOVERED" alert on the way back up:

if v_ok then
  if st.is_down then                      -- up-transition
    update external_uptime_monitors
      set is_down = false, down_since = null where url = t.url;
    perform notify_discord('✅ RECOVERED: ' || st.label);
  end if;
elsif st.consecutive_failures >= v_threshold then
  if not st.is_down then                  -- down-transition
    update external_uptime_monitors
      set is_down = true, down_since = now() where url = t.url;
    perform notify_discord('🚨 DOWN: ' || st.label || ' failed '
                            || st.consecutive_failures || '×');
  end if;
end if;

Threshold-based down-and-recovery transitions are the whole difference between a monitor you trust and one you mute.

Keep the secret out of git

The Discord webhook URL is a credential, so it does not live in the migration. It lives in a one-row config table written at runtime, and the poller reads it each tick. With no webhook row present the poller still records state — it just sends nothing. That means you can ship the whole thing before you've decided where alerts go, and it sits there inert until you wire the destination. (Same treatment for the function itself: SECURITY DEFINER, EXECUTE revoked from anon/authenticated, granted only to the service role — a forwarded end-user JWT can't reach it.)

The part we got wrong

Here is the honest bit, because the naive version above has a real flaw and it took production telemetry to see it.

http_get is synchronous. It blocks the Postgres backend it runs in for the entire duration of the request. We shipped it with a 10-second per-request timeout and no overall bound on the tick. A few weeks later, pg_stat_statements ranked run_external_uptime_check() first by total database time — ahead of every product query, by a wide margin. It wasn't slow per call; it was that a worst-case tick could hold a connection for (number of monitors) × 10 seconds, and the worst case — every monitor timing out at once — is precisely a real outage, which is exactly when a free-tier database with a small connection ceiling can least afford a backend tied up in a blocking curl.

The monitor was fine on every normal day and turned into a resource problem on the one abnormal day, which is the same failure mode we built it to catch, one level down. The fixes were unglamorous and worth stating plainly:

  1. A per-tick deadline. The loop stops probing once a time budget (we use 20s) is spent, and a skipped monitor's state is left untouched — "not measured" must not be written as "nothing wrong." A skip that would otherwise repeat every tick is logged at most once an hour so it can't flood the events table.
  2. Tighter per-request timeouts — 10s → 5s (connect 5s → 3s). A URL that hasn't answered in five seconds is the signal; waiting ten doesn't make the verdict better, it just holds the connection longer.

Worst-case backend occupancy is now bounded to roughly the tick budget plus one request, regardless of how many monitors you add. We deliberately did not move the poll to a Cloudflare Worker to dodge the resource cost — polling from independent infrastructure is the entire point, and giving that up to save a few seconds of connection time would have quietly re-introduced the correlated-failure blindness we started with.

What this does and doesn't cover

Be precise about the guarantee, because a monitor that claims more than it delivers is worse than none.

  • Covered: a Cloudflare-side failure — Workers erroring, Pages down, an edge/DNS problem, a bad deploy that 5xxs /readyz — is now seen by something that isn't on Cloudflare, within about ten minutes, with a real alert.
  • Not covered by this belt: if Supabase is down, the poller is down too. But our app depends on Supabase, so in that scenario the app is down and this specific watchdog is the wrong tool anyway — that's a correlated failure on the other provider, and it wants its own independent check. One external belt is good; two that fail independently of each other are better. This closes the gap that was widest for us; it doesn't claim to be the last monitor you'll ever need.

The general lesson generalizes past our stack: a self-hosted health check is a status light wired to its own power supply. Whatever platform your app lives on — Cloudflare, Vercel, Fly, a single VPS — the check that proves it's up has to run somewhere that can survive that platform going down. If it can't, it isn't a monitor, it's a thing that agrees with you until the moment you'd want it to argue.


We build this so you don't have to. Merlonix watches your sites and APIs — uptime, SSL expiry, DNS drift, domain expiry — from outside your stack, and tells you in plain language when something a customer would notice changes. The free tier monitors your first assets at no cost; you can check a domain's SSL and DNS right now without signing up.