How We Run a Monitoring SaaS on Cloudflare Workers + Supabase for Almost Nothing

Merlonix monitors uptime, SSL/TLS, DNS, email authentication, blacklists, Certificate Transparency, Core Web Vitals, and MCP servers for agencies. A monitoring product has an unforgiving shape: it must run continuously, hit arbitrary customer-supplied hostnames, and stay up more reliably than the things it watches — while, in our case, keeping the infrastructure bill within a rounding error of zero until revenue exists to justify more.

This post is the real architecture, including the parts that bit us. Nothing here is a reference design we aspire to; every component named below is deployed and verifiable from the outside.

The shape of the system

Compute: eight Cloudflare Workers. One HTTP API worker (Hono) serves everything under api.merlonix.com. Seven background workers do the actual monitoring: a scheduler (cron), a check-runner and vendor-runner (queue consumers that execute checks), a vendor-fetcher, a dlq-consumer (dead-letter forensics), a browser-runner, and a db-backup worker. Each has its own wrangler config and its own deploy verifier.

Frontend: two static Cloudflare Pages projects. The marketing site and the app are both Next.js static exports — no SSR servers, no origin to fall over. Anything dynamic goes through the API worker. A Pages Function provides the thin middleware layer (redirects, custom-domain status-page routing).

Glue: Cloudflare Queues. The scheduler enqueues due work onto checks-q and vendor-q; the runners consume in batches of 10. Failures retry, and exhausted retries land in a dead-letter queue with a consumer that records the forensic payload instead of dropping it.

State: one Supabase Postgres. Every table that holds tenant data runs with forced row-level security — the API worker uses the service role deliberately and narrowly, and RLS is audited by a script that enumerates deny-all tables and cross-tenant probes. The migration ledger is at 237 forward-only migrations, applied to production by an idempotent runner. There is no second database; the queue messages carry IDs, and Postgres is the single source of truth.

The revenue-gated cron throttle

The scheduler's production cron fires every minute. But firing and working are different things:

/** Revenue-gated SLA throttle. */
export function isSweepDue(now: Date, hasLiveSubscription: boolean): boolean {
  if (hasLiveSubscription) return true;
  return now.getUTCMinutes() % 5 === 0;
}

With no live customer, a sweep only does real work on every 5th UTC minute — byte-for-byte the enqueue volume of a 5-minute cron, which costs effectively nothing while there's nobody to monitor but seeded assets. The moment any revenue-bearing subscription exists, the very next tick restores full per-minute cadence. No redeploy, no flag flip, no human. The SLA follows the money automatically.

The interesting part is the failure mode we shipped and later fixed. The hasLiveSubscription check queries the subscriptions table, and the original catch block was bare:

} catch { hasLiveSubscription = false; }

Read that as an SRE: a transient database blip on this one query would silently throttle paying customers' monitoring cadence from 1 minute back to 5 — no error, no alert, checks just quietly late. It's the worst kind of degradation: invisible, revenue-adjacent, and plausible-deniable. The fix keeps the safe degradation (bootstrap mode beats crashing the sweep) but fans the failure out to Sentry and structured logs under its own error code, so a sustained degradation pages before a customer notices. If you take one pattern from this post: when you degrade gracefully, make the degradation loud.

SSRF-guarding the public probes

Our free tools and monitoring checks fetch URLs and hostnames that strangers type into a form. That is the textbook server-side request forgery setup: the classic target is 169.254.169.254, the cloud metadata endpoint, where a successful internal request leaks the execution environment's credentials.

Workers add a twist: fetch() doesn't expose the resolved IP and gives you no way to pin one. So the guard works like this:

  1. Pre-resolve via DNS-over-HTTPS (1.1.1.1) and check every A/AAAA record against a blocklist of loopback, RFC1918, link-local (metadata!), CGNAT, unspecified, and IPv6 unique-local/link-local ranges. Any private answer → reject before fetching.
  2. Redirects are followed manually, once. A malicious server can 302 to http://127.0.0.1; we re-run the full SSRF check on the Location header before following, and a second hop is always rejected.
  3. Responses are size-capped and time-capped so a hostile endpoint can't stall a worker or balloon memory.

And the honest residual, documented in the code rather than papered over: the runtime's fetch() performs its own DNS resolution, so a sub-TTL attacker could answer our DoH probe with a public IP and the runtime with a private one. IP-pinning isn't viable on Workers (TLS validates against SNI). The authoritative backstop is Cloudflare's platform egress policy — Workers cannot open connections into loopback/RFC1918/link-local regardless of DNS. Our DoH layer is defense-in-depth on top of that, and we say so, because an SSRF guard you overstate is worse than one you understand.

Watching the watcher, from a different cloud

A monitoring company that monitors itself with itself has a bootstrapping problem: if Cloudflare has an account-level bad day, the thing that would tell us is also having a bad day.

So the external watchdog doesn't run on Cloudflare at all. It runs inside Supabase — which sits on AWS — using pg_cron plus the http extension: every 5 minutes, a SECURITY DEFINER function curls merlonix.com and the API's /healthz and /readyz endpoints, tracks consecutive failures in a table, and posts to an operator Discord webhook after a sustained-failure threshold, with a single recovery notice when things come back. No third-party account, no additional bill, and — the actual point — no shared fate with the platform it watches.

Boring reliability plumbing that earns its keep

  • Every worker invocation writes a heartbeat row to a worker_runs ledger (worker name, outcome, duration). "Is the scheduler actually running?" is a SQL query, not a guess — and a failed heartbeat insert has its own alarm code, because observability that fails silently isn't observability.
  • Every deploy has a verifier script, and deploys aren't done until it passes: the API verifier checks live routes and CORS, the workers verifier catches a stale or forgotten worker plus queue/cron/binding drift via the Cloudflare REST API, and a headless-browser smoke pass loads the public pages and fails on console errors, first-party network failures, and layout regressions that string-matching verifiers are blind to. Rollback scripts are pre-written; a failed verifier means roll back, not debug-in-prod.
  • The dead-letter queue has a consumer. DLQs with no consumer are where failures go to be forgotten; ours records each exhausted payload so a storm becomes a diagnosable dataset. That mattered the day a decommissioned LLM model name plus a router that hard-threw instead of falling back flooded it — root-caused from the forensics, fixed, regression-guarded.

What it costs

Nearly nothing, and that's a design constraint, not an accident. Static Pages sites are free. Workers requests at our current scale sit comfortably inside the Workers plan floor. Supabase is on the free tier — the watchdog cron and RLS-forced Postgres both fit inside it. The paid-API checks that could cost real money (PageSpeed Insights, LLM-backed features) sit behind explicit flags, daily caps, and per-tenant meters, so the worst case of a bug is a rate-limit, not a bill. The whole stack is engineered so the monthly infrastructure bill stays within a rounding error of zero until customers exist — at which point the same cron throttle that saves money today upgrades their SLA on the next tick.

If you're building on this stack

The pattern that generalizes: cron fires cheap and constant; a pure function decides whether the tick does work. It gives you a testable throttle (isSweepDue is 3 lines and unit-tested), a zero-redeploy upgrade path, and one place where cadence policy lives. Pair it with loud degradation, verify every deploy from the outside, and put your last-resort watchdog on somebody else's cloud.

The product this architecture serves is Merlonix — monitoring for agencies, from uptime and SSL through MCP server health. The free tools run the same SSRF-guarded probe path described above; you can watch it work without signing up.