Uptime is a lightweight, serverless monitoring service that runs on a Cloudflare Workers cron trigger. Every few minutes it performs a configurable list of health checks, and when something goes down it opens a Telegram thread and (optionally) drives incidents on Statuspage.io — then updates and resolves them automatically as services recover.
It also watches itself: a dead-man's-switch heartbeat lets an external monitor alert you if the worker ever stops running.
Note
There's no server to run and nothing to keep alive — the whole thing is one Cloudflare Worker, a KV namespace, and a single Durable Object.
- Features
- How It Works
- Repository Layout
- Tech Stack
- Getting Started
- Configuration
- Notifications
- Flap Filtering
- Self-Monitoring (Dead-Man's-Switch)
- Deployment
- Local Development
- Scripts
- Contributing
- License
- Active monitoring — periodically probes each configured target on a cron schedule.
- Resilient checks — configurable timeout and retries with exponential backoff, so a single transient blip doesn't page you.
- Flap filtering — an optional
flapFilter.failureThresholdre-probes a failing check ~1 minute later via a Durable Object alarm and only alerts once the failure is confirmed, so a sub-minute ingress hiccup that hits every target at once never pages you. - Smart Telegram alerts — a single downtime message that edits itself in place as the set of failing checks changes, then gets a recovery reply once everything is back.
- Statuspage.io sync (optional) — maps each check to a component and manages the incident lifecycle: open → update → resolve, with an optional postmortem on recovery.
- Cloudflare Zero Trust support — probe sites behind Cloudflare Access using a service token, and treat an Access login page as a failure.
- Dead-man's-switch (optional) — pings an external heartbeat monitor after each completed run, so you're alerted if the monitor itself stops running.
- Serverless — runs entirely on Cloudflare Workers + Workers KV. No servers, no containers.
flowchart LR
cron([Cron every 5 min]) --> mon[[Monitor Durable Object]]
alarm([DO alarm ~1 min]) --> mon
mon --> checks[Run checks fetch + retry/backoff]
checks --> sm{Confirm via state machine}
sm -->|pending: re-probe| alarm
sm --> tg[Telegram alert]
sm --> sp[Statuspage incident]
tg --> kv[(Workers KV)]
mon --> hb([Heartbeat ping on success])
- A scheduled Worker fires on the cron defined in
packages/uptime-worker/wrangler.jsonc(default: every 5 minutes) and pokes the single Monitor Durable Object. - The Monitor probes each check in
uptime.config.ts(up to 2 concurrently). A non-expected status code, a Cloudflare Access login page, or a timeout is a failing probe — after exhausting its retries. - Each probe feeds a per-check confirmation state machine (
up → pending → down). A failure is only reported once it reaches the check'sfailureThreshold; while a check ispendingthe DO sets an alarm to re-probe just that check ~1 minute later, confirming (or clearing) the failure without waiting a whole cron interval. Once a check is confirmeddownthe alarm loop stops — recovery is detected by the next regular cron poke. See Flap Filtering. - The confirmed snapshot is handed to the notification channels:
- Telegram opens or edits a downtime message, and replies with a recovery notice when all checks pass again.
- Statuspage (if configured) sets each component's status and opens/updates/resolves a grouped incident.
- The DO holds the confirmation state in its own storage; each channel still persists just the state it needs (e.g. the Telegram message id) in Workers KV.
- Once the cron-driven cycle completes, an optional heartbeat ping is sent to an external dead-man's-switch.
This is an npm workspaces + Turborepo monorepo:
| Package | Description |
|---|---|
packages/uptime-worker |
The Cloudflare Worker: checks, notifications, and scheduling. |
packages/uptime-setup |
Interactive CLI (npm run setup) that provisions Cloudflare and secrets. |
packages/uptime-eslint |
Shared ESLint config. |
packages/uptime-test |
Shared Vitest config. |
packages/uptime-tsconfig |
Shared TypeScript config. |
- Cloudflare Workers + Cron Triggers
- Durable Objects (SQLite-backed) + Alarms for confirmation scheduling
- Workers KV for state
- Wrangler for local dev and deploys
- TypeScript
- Telegram Bot API via grammY
- LiquidJS for message templates
- Statuspage API (optional)
- Node.js 20+ and npm.
- A Cloudflare account.
- A Telegram bot token and the chat id to notify.
# 1. Clone
git clone https://github.com/hobroker/uptime.git
cd uptime
# 2. Install
npm install
# 3. Run the interactive setup
npm run setupThe setup wizard (packages/uptime-setup) will:
- Log you in to Cloudflare (via
wrangler login). - Create the
uptimeKV namespace and write its id intowrangler.jsonc. - Optionally prompt for and set your Telegram / Statuspage secrets.
- Create a local
.dev.varsfrom the example.
Then configure your monitors in packages/uptime-worker/uptime.config.ts (see Configuration) and deploy:
npm run deployPrefer to wire things up yourself? The wizard is optional:
- KV namespace —
npx wrangler kv namespace create uptime, then put the returned id underkv_namespacesinpackages/uptime-worker/wrangler.jsonc. - Secrets — set each with
npx wrangler secret put <NAME>(see Environment Variables & Secrets). - Local vars — copy
packages/uptime-worker/.dev.vars.exampleto.dev.varsand fill in values for local runs.
Monitors are defined in packages/uptime-worker/uptime.config.ts:
import { UptimeWorkerConfig } from "./src/types";
// Reusable helper for targets behind Cloudflare Access.
const zeroTrustAuth = ({ env }: { env: Env }) => ({
"CF-Access-Client-Id": env.CF_ACCESS_CLIENT_ID,
"CF-Access-Client-Secret": env.CF_ACCESS_CLIENT_SECRET,
});
export const uptimeWorkerConfig: UptimeWorkerConfig = {
statuspage: {
// Optional: link included in notifications.
url: "https://your-org.statuspage.io",
// Optional: create and publish a postmortem when an incident resolves.
autoPostmortem: false,
},
checks: [
{
name: "My Website",
target: "https://example.com",
retryCount: 2,
},
{
name: "Internal Service",
target: "https://internal.example.com",
headers: zeroTrustAuth,
retryCount: 1,
},
],
};Optional statuspage settings:
| Field | Type | Default | Description |
|---|---|---|---|
statuspage.url |
string |
— | Public status page URL included in Telegram notifications. |
statuspage.autoPostmortem |
boolean |
false |
Create and publish a postmortem when a Statuspage incident is resolved. Opt-in. |
Each check supports:
| Field | Type | Default | Description |
|---|---|---|---|
name |
string |
— | Display name (also the Statuspage component name). Required. |
target |
string |
— | URL to probe. Required. |
method |
string |
"GET" |
HTTP method. |
probeTarget |
string |
target |
Override the URL actually requested (e.g. a dedicated health endpoint). |
expectedCodes |
number[] |
[200] |
Status codes considered healthy. |
timeout |
number |
10000 |
Per-attempt timeout in ms. |
retryCount |
number |
0 |
Retries before marking down. Backoff is exponential, starting at 5s. |
flapFilter |
{ failureThreshold?, recheckInterval? } |
— | Confirm a failure before reporting it (see Flap Filtering). |
flapFilter.failureThreshold |
number |
1 |
Consecutive confirmed failures before a check is reported down. 1 reports on the first failure. |
flapFilter.recheckInterval |
number |
60000 |
Milliseconds the Monitor waits before re-probing a pending check to confirm or clear the failure. |
headers |
({ env }) => HeadersInit |
— | Function returning request headers (great for auth secrets). |
body |
({ env }) => BodyInit |
— | Function returning a request body. |
Set production values with npx wrangler secret put <NAME>; for local development put them in packages/uptime-worker/.dev.vars (see .dev.vars.example).
| Variable | Required | Purpose |
|---|---|---|
TELEGRAM_BOT_TOKEN |
✅ | Telegram bot token used to send/edit messages. |
TELEGRAM_CHAT_ID |
✅ | Chat that receives notifications. |
STATUSPAGE_IO_API_KEY |
optional | Enables Statuspage sync. |
STATUSPAGE_IO_PAGE_ID |
optional | Statuspage page to manage. |
HEARTBEAT_URL |
optional | Dead-man's-switch ping URL (see Self-Monitoring). |
CF_ACCESS_CLIENT_ID |
optional | Cloudflare Access service token id (used by the zeroTrustAuth helper). |
CF_ACCESS_CLIENT_SECRET |
optional | Cloudflare Access service token secret. |
Tip
The setup wizard prompts for the Telegram and Statuspage secrets. HEARTBEAT_URL and the CF_ACCESS_* service token are set manually with wrangler secret put.
The schedule lives in packages/uptime-worker/wrangler.jsonc:
Telegram — When one or more checks go down, Uptime posts a single message listing them. As the set of failing checks changes, it edits that same message rather than spamming new ones. When everything recovers, it replies to the thread with a recovery notice. Message bodies are rendered with LiquidJS and HTML-escaped, so upstream error text can't inject markup.
Statuspage.io (optional) — Each check maps to a component whose status is kept in sync (operational / major_outage). Failing checks are grouped into a single incident that is opened, updated as the affected set changes, and resolved on recovery. Set statuspage.autoPostmortem: true to also create and publish a postmortem.
Occasionally the shared ingress path in front of every target blips for under a minute — a Cloudflare Tunnel reconnect or a WAN hiccup — and a single cron run sees every check fail at once, even though the services are fine. Without protection that fires a full downtime alert and a Statuspage incident that resolves minutes later.
Flap filtering confirms a failure before reporting it. A single Monitor Durable Object owns a per-check state machine:
up ──probe down──▶ pending ──confirmed (≥ failureThreshold)──▶ down
▲ │ │
└──probe up──────────┘◀────────── probe up (recovery) ──────────┘
- up + failing probe →
pending(failures = 1). IffailureThreshold == 1it goes straight todown— the original report-on-first-failure behavior. - pending → the DO sets a single alarm and re-probes just the pending checks (not the full set) after
recheckInterval(~1 min). Another failure incrementsfailures; once it reachesfailureThresholdthe check is confirmed down. A passing probe clears it back to up with no alert — that's the flap being filtered. (One DO holds one alarm; if several checks are pending it fires at the shortestrecheckIntervaland re-probes them together.) - down → the failure has been reported, so the fast alarm loop stops. The check's recovery (and any further status change) is detected by the next regular cron sweep.
Only pending checks incur the fast alarm loop; everything else rides the normal cron cadence. The cron trigger stays as both the normal-cadence sweep — including recovery of down checks — and a safety net if an alarm is ever missed.
To require confirmation, set flapFilter.failureThreshold on a check (recheckInterval is optional, default 60000):
{
name: "My Website",
target: "https://example.com",
// must fail twice, ~1 min apart, before alerting
flapFilter: { failureThreshold: 2 },
}flapFilter.failureThreshold defaults to 1, so existing configs behave exactly as before. This is deliberately distinct from retryCount, which retries within a single run (seconds); flap filtering re-checks across real time (~1 min per confirmation) to ride out a short outage that spans a run but recovers shortly after.
A monitor that only speaks up when it runs can fail silently — if the Worker stops being scheduled or crashes before finishing, nothing tells you. To close that gap, set HEARTBEAT_URL to a ping URL from an independent heartbeat service (Dead Man's Snitch, healthchecks.io, BetterStack, Cronitor, …).
After each run that completes its check-and-notify cycle, Uptime sends a GET to that URL. If the Worker stops firing or the run crashes, the pings stop and the external service alerts you — through a path that doesn't depend on Cloudflare. Note that the heartbeat confirms the monitor ran, not that every alert was delivered: a failed Telegram or Statuspage delivery is logged independently and does not suppress the ping.
- Create a check on your provider; pick the coarsest interval that still catches real downtime (the 5-minute cron will ping comfortably within it).
npx wrangler secret put HEARTBEAT_URLwith the ping URL.- Route that provider's alert wherever you like (e.g. the same Telegram chat).
Deploy manually at any time with:
npm run deploy # turbo -> wrangler deployFor continuous deployment, connect the repository to Cloudflare Workers Builds in the Cloudflare dashboard — Cloudflare then builds and deploys on every push to your default branch. (Deployment is handled natively by Cloudflare; there is no GitHub Actions deploy workflow.)
# Start the worker locally (with scheduled-handler testing enabled)
npm run devUptime is driven by a Cron Trigger, so there's no page to visit. Simulate a scheduled run by hitting the /__scheduled endpoint Wrangler exposes:
curl "http://localhost:8787/__scheduled?cron=*+*+*+*+*"Run from the repo root; Turborepo fans them out across packages.
| Script | Description |
|---|---|
npm run setup |
Interactive Cloudflare + secrets setup wizard. |
npm run dev |
Run the worker locally with --test-scheduled. |
npm run deploy |
Deploy the worker with Wrangler. |
npm test |
Run the Vitest suites. |
npm run lint |
Lint all packages. |
npm run ts-check |
Generate Cloudflare types and type-check. |
npm run format |
Format with Prettier. |
npm run cf-typegen |
Regenerate Worker binding types from wrangler.jsonc. |
Contributions are welcome — issues and pull requests alike. Before opening a PR, please make sure the checks pass:
npm run lint
npm run ts-check
npm testCI runs these on every push, so it's the same gate your PR will face.
Licensed under the MIT License. See LICENSE for details.