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

> Source: <https://dev.to/shannonops/building-a-server-monitoring-saas-on-cloudflare-workers-architecture-decisions-mistakes-7em>
> Published: 2026-08-19 01:33:00+00:00

**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.

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:

Everything downstream was designed around those constraints.

Pulse is one Cloudflare Worker (`pulse-api`

) that serves five kinds of traffic:

`/app/*`

routes for authenticated users`/agent/register`

and `/ingest`

for the Go agent to talk to`/silence`

, `/status`

)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));
  }
}
```

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"
```

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

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.

``` js
// 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;
}
```

Why the boring version wins here:

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.

`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
```

The `install.sh`

is generated by the Worker itself — no external CDN, no GitHub release page:

``` js
// 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"
`;
```

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}"`,
    },
  });
}
```

Why Go instead of Rust or Python:

`CGO_ENABLED=0`

runs.`gopsutil`

is the best cross-platform metrics library in any language I've used.`sysinfo`

doesn't match `gopsutil`

.Why R2 for binaries:

`curl`

the binary, they just can't do anything with it without a valid invite.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:

``` js
// 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 });
}
```

Notice the two-step auth:

`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:

`WHERE tenant_id = ?`

where the tenant_id comes from the authenticated session. The session is a JWT signed with `SESSION_SECRET`

, verified server-side.`tenant_id`

from the token-verified host row. It's never taken from the client body.`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.

`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:

``` js
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");
```

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.

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 "";
  }
}
```

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.

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.

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](https://pulse.shannonops.com?utm_source=devto&utm_medium=article&utm_campaign=architecture). 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.*
