DEV Community

Cover image for Building a server monitoring SaaS on Cloudflare Workers — architecture, decisions, mistakes
Manfredo Martínez
Manfredo Martínez

Posted on

Building a server monitoring SaaS on Cloudflare Workers — architecture, decisions, mistakes

TL;DR

Pulse is a server monitoring SaaS I built as a solo founder for Latin American small businesses. It runs entirely on Cloudflare Workers + Turso (libsql) + R2, with a Go agent that installs on Linux, macOS, Windows, or Docker via a single curl command. The whole platform costs about $5 per month to operate for the first hundred customers and hits sub-second alert latency at the edge.

Stack in one line:
Cloudflare Workers for compute, Turso (libsql) for the multi-tenant database, R2 for agent binary delivery, Workers AI (Llama 3.3) for alert interpretation, Resend for magic-link email, Telegram Bot API for alerts, and Stripe for billing.

The interesting part isn't the stack itself — it's the tradeoffs. I'll walk through five decisions that shaped how the thing actually works, with code from the real repo. No inventory of features, just the architecture and what I'd change if I started again.


The problem I set out to solve

Small and medium businesses in Latin America don't fit the pricing model of enterprise-grade observability tools. A local e-commerce with 15 servers cannot justify a monitoring bill that scales unpredictably with logs and metrics ingested. And the free/self-hosted alternatives require operational skills that a two-person infra team doesn't have to spare.

The result is that most LATAM SMBs simply don't have real monitoring. Outages get detected when a client calls. That's the market Pulse exists for.

Constraints I set for myself:

  • Fixed monthly price for each tier, no per-metric fees, no surprise invoices.
  • Setup in under 5 minutes with a single command — no config files, no OAuth dance.
  • Native LATAM channels: Telegram alerts (the actual messaging app people read) and Spanish-language dashboards.
  • Alert latency under 1 second from threshold breach to notification.
  • Single-founder operable indefinitely. If I have to hire someone to keep it running, the unit economics break.

Everything downstream was designed around those constraints.


The overall architecture

Pulse is one Cloudflare Worker (pulse-api) that serves five kinds of traffic:

  1. Public HTML — landing, pricing, public status pages
  2. Dashboard — the /app/* routes for authenticated users
  3. Agent API — /agent/register and /ingest for the Go agent to talk to
  4. Telegram webhook — for two-way commands (/silence, /status)
  5. Stripe webhook — subscription events

Plus a scheduled handler that runs every minute:

// src/index.js
async scheduled(event, env, ctx) {
  ctx.waitUntil(evaluateAlerts(env));
  ctx.waitUntil(runChecks(env));

  const scheduledMinute = new Date(event.scheduledTime).getUTCMinutes();
  if (scheduledMinute === 5) {
    ctx.waitUntil(runRetention(env));
  }
}
Enter fullscreen mode Exit fullscreen mode

The cron does three things: evaluate thresholds, run HTTP uptime checks, and once an hour (at minute 5) run retention cleanup. That's it. There is no separate cron worker, no queue, no background service.

The wrangler.toml shows the whole binding surface:

name = "pulse-api"
main = "src/index.js"
compatibility_date = "2026-08-01"

[[routes]]
pattern = "pulse.shannonops.com"
custom_domain = true

[vars]
TURSO_DATABASE_URL = "libsql://pulse-db-shannonops.aws-us-east-2.turso.io"
PUBLIC_URL = "https://pulse.shannonops.com"

[[r2_buckets]]
binding = "RELEASES"
bucket_name = "pulse-releases"

[triggers]
crons = ["* * * * *"]

[ai]
binding = "AI"
Enter fullscreen mode Exit fullscreen mode

One worker, one database, one bucket, one cron. That's the whole platform.


Decision 1 — Every-minute cron for alert evaluation instead of a real-time stream

The temptation was to build a streaming pipeline: ingest a sample, evaluate rules in-line, fire an alert if a threshold is breached. That's how enterprise tools do it.

I picked the boring alternative: a plain cron trigger every 60 seconds that reads all enabled thresholds, joins them against recent samples, and emits alerts.

// src/alerts.js
export async function evaluateAlerts(env) {
  const client = turso(env);
  const now = Math.floor(Date.now() / 1000);
  const stats = { evaluated: 0, fired: 0, resolved: 0, notified: 0 };

  const thRes = await client.execute({
    sql: `SELECT id, tenant_id, host_id, metric, operator, value,
                 duration_min, severity, name, enabled
          FROM thresholds WHERE enabled = 1`,
    args: [],
  });

  const hostRes = await client.execute({
    sql: `SELECT id, tenant_id, hostname, last_seen_at, silenced_until FROM hosts`,
    args: [],
  });
  const hostsById = new Map(hostRes.rows.map(r => [String(r.id), r]));

  for (const th of thRes.rows) {
    const targetHosts = [...hostsById.values()]
      .filter(h => String(h.tenant_id) === String(th.tenant_id))
      .filter(h => !th.host_id || String(h.id) === String(th.host_id));

    for (const h of targetHosts) {
      if (h.silenced_until && Number(h.silenced_until) > now) continue;
      stats.evaluated++;
      const violation = await evaluateThresholdForHost(client, th, h, now);
      if (violation.action === "fire") stats.fired++;
      if (violation.action === "resolve") stats.resolved++;
    }
  }

  await notifyPending(client, env);
  return stats;
}
Enter fullscreen mode Exit fullscreen mode

Why the boring version wins here:

  • Sub-second latency is not actually required. A threshold like "CPU above 90% for 5 minutes" already implies a 5-minute detection window. Adding streaming to shave milliseconds off the alert time is wasted engineering.
  • A cron every 60 seconds means the worst-case latency is 60 seconds. For the "server is on fire" alert, that's fine. Telegram push delivery from the moment the cron fires is closer to 1.5 seconds — the actual observed latency people see.
  • Cron triggers are free on Cloudflare Workers. No queue infrastructure, no separate DO, no polling loop.
  • The whole alert engine fits in ~500 lines. I can reason about it during a bad night.

If I ever needed real sub-second alerts (say, for financial systems), I'd add a fast-path: evaluate a small subset of thresholds inline on POST /ingest. But I haven't needed to.


Decision 2 — A Go agent, curl | sudo bash install, and R2 for binary delivery

The agent is a static Go binary compiled with gopsutil for cross-platform metrics collection. Around 6MB, no runtime dependencies. Users install it with:

curl -sSL https://pulse.shannonops.com/install.sh | sudo INVITE=xxx bash
Enter fullscreen mode Exit fullscreen mode

The install.sh is generated by the Worker itself — no external CDN, no GitHub release page:

// src/install.js (excerpt)
const INSTALL_SH = `#!/usr/bin/env bash
set -euo pipefail

API_URL="\${PULSE_API:-https://pulse.shannonops.com}"
INVITE_TOKEN="\${INVITE:-}"

OS="\$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH="\$(uname -m)"
case "\$ARCH" in
  x86_64|amd64) ARCH="amd64" ;;
  aarch64|arm64) ARCH="arm64" ;;
esac

BIN_URL="\$API_URL/dl/pulse-agent-\$OS-\$ARCH"
BIN_DEST="/usr/local/bin/pulse-agent"

curl -fsSL -o "\$BIN_DEST" "\$BIN_URL"
chmod 755 "\$BIN_DEST"
"\$BIN_DEST" -api "\$API_URL" -invite "\$INVITE_TOKEN"
`;
Enter fullscreen mode Exit fullscreen mode

The /dl/pulse-agent-<os>-<arch> handler streams the binary directly from R2:

// src/install.js (excerpt)
export async function handleDownload(request, env, filename) {
  const obj = await env.RELEASES.get(filename);
  if (!obj) return new Response("Not Found", { status: 404 });
  return new Response(obj.body, {
    headers: {
      "content-type": "application/octet-stream",
      "content-disposition": `attachment; filename="${filename}"`,
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

Why Go instead of Rust or Python:

  • Static compilation. Zero runtime, zero deps to install on the target machine. On old Debian 10 with an ancient glibc, a Rust binary might refuse to link. A Go binary compiled with CGO_ENABLED=0 runs.
  • gopsutil is the best cross-platform metrics library in any language I've used. Linux, macOS, Windows, and FreeBSD from the same source.
  • The binary is ~6MB. Python + agent code + deps is easily 40MB. Rust is competitive but the maintainer velocity for sysinfo doesn't match gopsutil.

Why R2 for binaries:

  • One bucket, same origin as the API. No CORS, no separate CDN.
  • Signed URLs weren't necessary — the invite token gates registration, not download. Anyone can curl the binary, they just can't do anything with it without a valid invite.
  • Egress is free on R2, which matters when hundreds of servers pull agent updates on release day.

Decision 3 — Multi-tenant on Turso without row-level security

Turso is libsql, which is SQLite with a network protocol. SQLite doesn't have row-level security. So how do you build a multi-tenant SaaS on it safely?

The answer that worked for me: every row has a tenant_id column, and every query filters by tenant explicitly, and the ingest endpoint verifies token-to-host ownership before touching the database.

Here's the /ingest endpoint:

// src/agent.js
export async function handleIngest(request, env) {
  const auth = request.headers.get("authorization") || "";
  const token = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
  if (!token) return json({ error: "unauthorized" }, 401);

  const body = await request.json();
  const hostId = String(body.host_id || "").trim();
  const samples = Array.isArray(body.samples) ? body.samples : [];

  if (!hostId || samples.length === 0) {
    return json({ error: "empty_batch" }, 400);
  }
  if (samples.length > 200) {
    return json({ error: "batch_too_large" }, 413);
  }

  const client = turso(env);

  // Verify token belongs to this host — this is the tenant boundary
  const tokenHash = await sha256hex(token);
  const hostRow = await client.execute({
    sql: `SELECT id, tenant_id FROM hosts
          WHERE id = ? AND token_hash = ? LIMIT 1`,
    args: [hostId, tokenHash],
  });
  if (hostRow.rows.length === 0) {
    return json({ error: "invalid_token" }, 401);
  }
  const tenantId = hostRow.rows[0].tenant_id;

  // From here on, every insert carries tenant_id explicitly
  const stmts = samples.map(s => ({
    sql: `INSERT INTO samples (host_id, tenant_id, ts, cpu_pct, ...)
          VALUES (?, ?, ?, ?, ...)`,
    args: [hostId, tenantId, s.ts, s.cpu_pct, ...],
  }));

  await client.batch(stmts, "write");
  return json({ ok: true, ingested: samples.length });
}
Enter fullscreen mode Exit fullscreen mode

Notice the two-step auth:

  1. The token identifies the agent (SHA-256 hashed at rest, generated with 24 random bytes).
  2. The token must match the specific host_id sent in the body. A stolen token from server A cannot post samples pretending to be server B.

Why this works without RLS:

  • Every dashboard query is scoped by WHERE tenant_id = ? where the tenant_id comes from the authenticated session. The session is a JWT signed with SESSION_SECRET, verified server-side.
  • Every ingest query derives tenant_id from the token-verified host row. It's never taken from the client body.
  • The hosts.token_hash column has a unique index, so lookup is fast.

Would I use D1 (Cloudflare's SQLite offering) instead of Turso today? Probably yes — D1 has caught up in features and doesn't require an external token. When I started, Turso had better regional replication for LATAM.


Decision 4 — Batch ingest with client.batch() to minimize subrequests

Cloudflare Workers on the free plan cap requests at 50 subrequests per invocation. Every call to client.execute() or client.batch() counts as one subrequest to Turso. If the agent sends 60 samples in one HTTP POST and I insert them one-by-one, I'd blow through the limit on the first ingest.

The fix is the batch primitive in the libsql client. All statements go in a single transaction, one subrequest:

const stmts = samples.map(s => ({
  sql: `INSERT INTO samples (host_id, tenant_id, ts, cpu_pct, ...)
        VALUES (?, ?, ?, ?, ...)`,
  args: [hostId, tenantId, s.ts, s.cpu_pct, ...],
}));

stmts.push({
  sql: `UPDATE hosts SET last_seen_at = ?, agent_ver = ? WHERE id = ?`,
  args: [now, agentVer, hostId],
});

await client.batch(stmts, "write");
Enter fullscreen mode Exit fullscreen mode

The agent buffers 60 samples locally (one per second, one minute of data) and posts them together every 60 seconds. That's 1 HTTP request per agent per minute, and inside the Worker, 1 subrequest to Turso for the whole batch.

If you're building on Cloudflare Free and hit "Too many subrequests," this is almost always the fix. Batch, batch, batch.

I learned this the hard way when a separate growth-agent Worker I run started failing silently at 51 subrequests. Splitting cron phases and batching writes solved it without upgrading to Paid.


Decision 5 — Workers AI for alert interpretation

When a threshold fires, the alert notification includes not just "CPU is high" but a short natural-language explanation: what changed, what usually causes this, and what to check first. That's Workers AI (Llama 3.3 70B fp8-fast) called with the last 10 samples as context.

Cost: zero on the current plan for the volumes I have. Latency: 400-800ms for a short response.

Snippet from the notification path:

async function generateAlertContext(env, host, threshold, samples) {
  if (!env.AI) return "";
  try {
    const resp = await env.AI.run(
      "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
      {
        messages: [
          { role: "system", content: "..." },
          { role: "user", content:
              `Host ${host.hostname}: ${threshold.metric} = ${samples[0].value}, ` +
              `threshold ${threshold.operator} ${threshold.value}. ` +
              `Last 10 samples: ${JSON.stringify(samples.slice(0, 10))}. ` +
              `In 2 sentences, what likely happened and what to check.`,
          },
        ],
        max_tokens: 200,
        temperature: 0.3,
      }
    );
    return String(resp?.response || "").trim();
  } catch (e) {
    console.error("[ai] alert-context err:", e?.message);
    return "";
  }
}
Enter fullscreen mode Exit fullscreen mode

The interesting part is not the code — it's that this feature adds real customer value without a separate ML infrastructure. It runs on the same worker, uses the same billing, and if the AI call fails the alert still ships without it. That fallback discipline is what makes AI features tolerable in a production system.


What I would do differently

Three things, if I were starting over today.

Split the API from the dashboard from day one. Right now the same worker serves the marketing landing, the dashboard, the agent API, and the webhook endpoints. It works, but it means a bad SQL query on the dashboard can slow down agent ingestion. Two workers with clear boundaries would be safer.

Use D1 instead of Turso. Turso's regional replication was a real advantage in early 2026 for LATAM latency. D1 has caught up. Staying inside the Cloudflare ecosystem simplifies the auth surface.

Write the retention job as a separate cron trigger, not piggybacked on the minute cron. The if (scheduledMinute === 5) pattern is cute but fragile. If retention takes longer than 60 seconds, it collides with the next minute's alert evaluation. A dedicated 0 * * * * cron would isolate them.


What this looks like in practice

The whole platform costs about 5 USD per month to operate for the first 100 customers (Turso Pro tier + Resend + Stripe fees). Alert latency observed in production is 1.2 to 1.8 seconds from threshold breach to Telegram push, dominated by Telegram's own delivery time. Agent install time on a fresh Ubuntu 22.04 VM is 22 seconds from curl to first sample ingested.

The stack shape — one Worker, one database, one bucket, one cron — is deliberately unglamorous. It's what lets me build six SaaS products in six months as a solo founder without hiring anyone.

If you want to see the running product: pulse.shannonops.com. Trial is 14 days. Feedback on the architecture is welcome in the comments — I'll answer everything.


This is the first article in a series on building the ShannonOps ecosystem — six micro-SaaS products for LATAM SMBs on Cloudflare Workers. Next up: how the Growth Agent runs daily cold outreach on the same stack.

Top comments (0)