← Blog
Infrastructure 12 min read

Cloudflare Workers for SaaS: How We Run PingBase for $5/Month

PingBase is an uptime monitoring SaaS. It runs monitor checks every minute, from multiple regions, for thousands of monitors. The infrastructure cost is roughly $5/month. Here's how that's possible — and what we'd do differently if we were starting over.

When you hear "SaaS infrastructure for $5/month" you expect caveats — toy load, a handful of users, a hobby project. PingBase isn't that. It runs scheduled checks against real URLs every 60 seconds, stores response time history, serves status pages to end users, and sends alerts through multiple channels. The reason it's cheap is that it's built entirely on Cloudflare's platform.

This post is a detailed breakdown of what we use and why. If you're building a SaaS and trying to keep infrastructure costs near zero in the early stage, there's a lot here worth understanding.


The stack

Component Cloudflare product Purpose
API serverWorkersHandles all HTTP API requests
Scheduled checksWorkers (Cron Triggers)Runs monitor checks every minute
DatabaseD1Stores monitors, checks, users, incidents
Session stateDurable ObjectsPer-monitor state, alert debouncing
KV storageWorkers KVCached status page data, uptime stats
App frontendPagesReact dashboard, served from edge
Marketing sitePagesStatic HTML, CDN-hosted globally
EmailResendAlert emails, welcome emails

No VMs. No containers. No managed Postgres. No load balancers. No Redis. Everything either runs at the edge or is a managed service with a generous free tier.


Workers: the core compute layer

Cloudflare Workers is serverless JavaScript running at the edge — in 300+ data centers globally. Requests are routed to the nearest location, cold starts are sub-millisecond (Workers uses V8 isolates, not containers), and the pricing model is pay-per-invocation with a large free tier.

The Workers free tier includes 100,000 requests/day. Our API Worker handles all dashboard API calls, authentication, monitor CRUD, alert configuration, and status page serving. At our current scale, we're well within free. Even at substantial paid scale, the Workers paid plan is $5/month for 10 million requests — more than any early-stage SaaS needs.

The critical architectural decision was using Workers Cron Triggers for monitor scheduling. Every minute, a cron trigger fires a Worker that reads all active monitors due for a check and dispatches them. The check itself is a simple HTTP fetch with a timeout:

# Monitor check logic (simplified)

export default {
  async scheduled(event, env, ctx) {
    const monitors = await getMonitorsDueForCheck(env.DB);

    await Promise.allSettled(
      monitors.map(monitor => checkMonitor(monitor, env))
    );
  }
};

async function checkMonitor(monitor, env) {
  const start = Date.now();
  try {
    const res = await fetch(monitor.url, {
      method: monitor.method || 'GET',
      signal: AbortSignal.timeout(30_000),
      headers: monitor.headers || {},
      redirect: 'follow',
    });

    const elapsed = Date.now() - start;
    const ok = res.status === (monitor.expected_status || 200);
    const slow = monitor.slow_threshold && elapsed > monitor.slow_threshold;

    await recordCheck(env.DB, monitor.id, {
      status: ok && !slow ? 'up' : (slow ? 'degraded' : 'down'),
      status_code: res.status,
      response_time: elapsed,
    });
  } catch (err) {
    await recordCheck(env.DB, monitor.id, {
      status: 'down',
      error: err.message,
    });
  }
}

Workers runs this globally — the check originates from the data center nearest to the target URL. For multi-region monitoring, we fan out the check to Workers in multiple regions using the cf.colo routing hint.


D1: SQLite at the edge

D1 is Cloudflare's serverless SQL database — SQLite under the hood, accessed from Workers with a simple query API. The free tier is 5 million rows read/day and 100k writes/day. That's enough for an early-stage SaaS with room to spare.

Our schema is straightforward: users, monitors, check_results, incidents, alert_channels, status_pages. The check_results table is the hot one — every check per monitor per minute writes a row. We keep 90 days of history and run a nightly cleanup job to prune older rows.

# D1 query from a Worker

const results = await env.DB.prepare(`
  SELECT
    date(checked_at) as day,
    AVG(response_time) as avg_ms,
    COUNT(*) FILTER (WHERE status = 'up') * 100.0 / COUNT(*) as uptime_pct
  FROM check_results
  WHERE monitor_id = ?
    AND checked_at > datetime('now', '-30 days')
  GROUP BY day
  ORDER BY day
`).bind(monitorId).all();

The main D1 limitation we hit: it's eventually consistent for reads from non-primary regions. For most SaaS reads this doesn't matter — a dashboard view can tolerate a few hundred milliseconds of staleness. For alert logic we use Durable Objects, which are strongly consistent.


Durable Objects: the alert state machine

This is the most interesting architectural piece. Durable Objects are a Cloudflare primitive: each object is a stateful singleton that lives in exactly one location. You can store state in it and mutate that state with strong consistency guarantees. It's essentially a tiny VM that runs your code and persists a key-value store.

We use one Durable Object per monitor. Its state machine handles:

# Durable Object state (simplified)

export class MonitorState {
  constructor(state, env) {
    this.state = state;
    this.env = env;
  }

  async fetch(request) {
    const { checkResult } = await request.json();
    const stored = await this.state.storage.get('state') ?? {
      status: 'up',
      consecutive_failures: 0,
      last_alert_at: null,
    };

    if (checkResult.status === 'down') {
      stored.consecutive_failures++;
      if (
        stored.consecutive_failures >= FAILURE_THRESHOLD &&
        stored.status !== 'down' &&
        !withinCooldown(stored.last_alert_at)
      ) {
        stored.status = 'down';
        stored.last_alert_at = Date.now();
        await sendAlerts(this.env, checkResult);
      }
    } else {
      if (stored.status === 'down') {
        await sendRecoveryAlerts(this.env, checkResult);
      }
      stored.consecutive_failures = 0;
      stored.status = checkResult.status;
    }

    await this.state.storage.put('state', stored);
    return new Response('ok');
  }
}

The beauty of this model: the entire alert logic is inside a strongly-consistent, single-location process. No race conditions. No duplicate alerts from two Workers firing simultaneously. No Redis needed for distributed locks.


Workers KV: cached read path

Status pages are read by potentially many users simultaneously during an incident — exactly when you can't afford your database to be hammered. We solve this with Workers KV: when a check result is written, we also write the current monitor status and a fresh uptime calculation to KV. The status page Worker reads from KV, not D1.

KV reads are free (up to 10 million/day on free tier), served from edge cache, and sub-millisecond. A busy status page during an incident can serve thousands of requests without any D1 load.


Pages: frontend and marketing site

Both the React dashboard app and this marketing site are deployed as Cloudflare Pages projects. Pages deploys static assets to the global CDN automatically — no configuration, no origin server. The marketing site is pure static HTML; the dashboard is a Vite-built React app with Pages Functions for server-side rendering.

Pages is free for unlimited sites and static deployments. It's the best zero-cost CDN for frontend assets that exists.


What this actually costs

Product Free tier Our usage Monthly cost
Workers100k req/day free~50k/day at early scale$0
Workers paid plan (unlocks D1/DO)Required for D1 + Durable Objects$5
D15M reads/day, 100k writes/dayWithin free tier$0
Durable Objects1M requests/month freeWithin free tier$0
Workers KV100k reads/day, 1k writes/dayWithin free tier$0
PagesUnlimited static deploysFree forever$0
Resend (email)3k emails/month freeWithin free tier$0
Total$5/month

The $5/month is the Workers paid plan. It's required to unlock D1 and Durable Objects, but the services themselves are free within generous limits. At meaningful scale we'd start paying for D1 reads and DO requests — but by then revenue would far exceed costs.


What we'd do differently

Durable Objects have rough edges. The developer experience for testing Durable Objects locally is still awkward — wrangler dev emulates them but there are subtleties that only appear in production. The migration story for changing DO state schema is also manual. We'd still use them — the consistency model is genuinely valuable — but plan more time for testing.

D1 is still maturing. The query API is solid but tooling around migrations and schema management is basic. We use a simple versioned migration runner inside the Worker itself. It works, but a proper migration tool would be better.

Observability is harder. With no traditional server, there's no syslog, no persistent process to attach a debugger to, no easy "tail -f access.log". Cloudflare's built-in logging and the wrangler tail CLI work well enough, but if you're used to traditional server observability, there's an adjustment.


Is this viable for your SaaS?

Cloudflare Workers is a good fit when:

It's a worse fit when you need complex relational queries (D1 is SQLite — no full-text search, no advanced indexing), large binary storage (use R2 or S3), or long-running processes (Workers have a 30-second execution limit on most plans).

For PingBase it's been an excellent fit. The entire platform scales automatically, deploys in seconds, and costs less than a single Heroku dyno.

Built on the edge. Free to start.

PingBase monitors your sites every minute from multiple regions. Free for up to 5 monitors.

Start monitoring free →

Related