~blogcase-study-blip-uptime-monitor

A multi-tenant uptime monitor on one Cloudflare Worker (D1 + Cron)

2026-08-04 · 5 min read · case study · cloudflare · serverless · blip

Blip is an uptime monitoring platform that runs entirely on one Cloudflare Worker backed by D1. Multi-tenant, role-based, with public status pages. It monitors my client sites, my own projects, and my homelab, and there is no server anywhere in it for me to patch.

Source: github.com/warlock277/blip

The problem

Two years ago I wrote a map of everything I run and had to admit at the end of it that nothing was watching any of it. This is what I eventually did about that.

I had three categories of thing to watch and no good option for any of them.

Hosted monitoring is the obvious answer, and it charges monthly per check or per monitor. Fine for one production system, hard to justify across a dozen small sites and a homelab that exists partly so I can stop paying for things.

Self-hosted monitoring solves the bill and creates a worse problem: the monitoring server is itself a server. It needs a host, patches, a disk that does not fill, and something to monitor it. A monitoring system that can go down silently is a monitoring system that will, at the worst possible moment, have been down for a week.

The homelab added a third constraint. Monitoring private services usually means either exposing them or granting the monitor network access into the lab. I spend real effort keeping ports closed. I was not going to open them for a health check.

Constraints I set before writing code

Nothing permanently running that I have to operate. No monthly bill at my usage. Multiple tenants with separate data, because clients and personal projects should not share a namespace. Public status pages, since half the value of monitoring is telling other people. And it had to work for services that are not publicly reachable.

The core trade-off

A Worker is not a daemon. That single fact drove the whole design.

Traditional monitors run a loop: a long-lived process holds a schedule in memory, sleeps, wakes, checks, and writes. None of that is available. A Worker is invoked, does a bounded amount of work, and exits. Execution time is limited, memory is limited, and there is no process to hold state between invocations.

So the monitoring model had to fit a scheduled-execution shape:

   cron trigger                     HTTP request
        │                                │
        v                                v
  ┌───────────────────────────────────────────────┐
  │              one Cloudflare Worker            │
  │                                               │
  │   scheduled handler       fetch handler       │
  │   - due checks only       - dashboard API     │
  │   - bounded batch         - status pages      │
  │   - fan out, collect      - tenant auth       │
  └────────────────────┬──────────────────────────┘
                       v
                 ┌───────────┐
                 │    D1     │  monitors · results · tenants · roles
                 └─────┬─────┘
                       v
          dashboard  +  public status pages

The invocation decides what is due, checks that batch, writes results, and exits. State lives in D1 between invocations rather than in a process. Check frequency becomes a function of how much work fits in one invocation, not of how often you feel like sleeping.

That is a real constraint, not a free win. Sub-minute checks are not what this architecture is for. For “is the client’s site up, and was it up at 3am”, it is exactly right.

What I built

The pieces that mattered:

Piece Why it exists
Scheduled handler Selects due monitors and runs a bounded batch per invocation
D1 schema Monitors, check results, tenants, users, roles. Tenant id on every access path.
RBAC Clients see their own monitors. Nobody browses somebody else’s.
Status pages Public read path, generated from the same result data, no auth
Dashboard API The same Worker’s fetch handler, so there is one deployable

The multi-tenancy decision I would repeat: tenant identity is part of every query, not a filter applied afterwards by the application when it remembers to. A missing WHERE tenant_id should fail to compile in your head before it fails in production.

What “$0/month” does and does not mean

I am careful about this claim because it is the easiest thing on my whole site to overstate.

It is free at my usage, inside the free tiers for Workers invocations, CPU time per invocation, and D1 storage and row reads. It stops being free if you push check frequency up, monitor count up, or history retention out. More monitors at a tighter interval means more invocations and more rows, and both have limits with prices attached beyond them.

The honest version: this design removes the fixed cost of owning a monitoring server and replaces it with a variable cost that is zero at small scale. That is the win. “Free forever at any scale” is not a claim I can defend, so I do not make it.

Result

It runs. It monitors client sites, personal projects, and homelab services, and it has no host of its own to fail. When Cloudflare deploys my Worker, the monitoring system is fully updated, because there is exactly one deployable and no fleet.

The thing I did not anticipate: removing the server removed a whole category of work I had stopped noticing. No OS updates, no certificate renewal, no disk alerts, no “is the monitoring box itself up” question. That question has no good answer in the self-hosted design and it simply does not arise here.

Lessons

Match the execution model, do not fight it. My first instinct was to simulate a loop inside a request-scoped runtime. Every design that started that way got worse. Accepting “you get invoked, do bounded work, exit” as the premise produced a simpler system than the one I would have written on a VM.

A constraint you cannot buy your way out of improves the design. No long-running process meant no in-memory state, which meant every piece of state had to be written down and therefore survives a deploy, a restart, and a region change. I got durability because I was not allowed to be lazy about it.

Multi-tenancy is a schema decision, not a feature. Retrofitting tenant isolation into queries written single-tenant is a rewrite. Putting the tenant key in from the first migration cost nothing.

Serverless moves operations, it does not delete them. I no longer patch a host. I now think about invocation budgets, row counts, and free-tier boundaries. That is a trade I would make again, and it is a trade, not an escape.

Technologies: Cloudflare Workers, D1, TypeScript, cron triggers, RBAC, public status pages.