← All posts

From Flareact to Astro: a Cloudflare status page you can fork

April 2026 · Platforms

My GitHub profile is mostly small, practical repos. The one with the clearest arc — and the best “I did not expect the deploy pipeline to fight me here” story — is cf-workers-status-page-1: a fork of eidam/cf-workers-status-page that still does the same job — HTTP checks on a schedule, history in KV, Slack / Telegram / Discord when status changes — but ships as an Astro app with SSR on Cloudflare Workers instead of the original Flareact + Workers Sites layout.

Live example: status.tikvite.org

This post is the opinionated version: why the migration was worth it, where Cloudflare politely ignored my wrangler.toml, and how to fork and deploy without losing an evening to “cron says Error and CPU time is basically zero.”

Why leave Flareact for Astro?

The upstream project was a good fit for its time: React on the edge, cron + KV, one Worker artifact. Today, maintaining that stack means babysitting a narrower React-on-Workers path and build plumbing that is not where the ecosystem is throwing documentation and fixes.

The migration goals were deliberately unsexy:

GoalFlareact-era painAstro-era payoff
UI delivery“Everything is React”HTML-first pages; React only where you opt in
AssetsWorkers Sites mental overheadASSETSdist/client, one mental model with the adapter
Cron + HTTPSingle bundle, easy to reason aboutSame idea, but explicit fetch vs scheduled in a tiny wrapper
Future youTouching UI risks touching edge glueFile routes, clear server boundaries, normal npm scripts

Astro fits that: file-based routes, optional React (@astrojs/react), server endpoints when you need them, and an official Cloudflare adapter that speaks the same language as wrangler deployif you teach Wrangler which file is actually your entry (more on that below).

The spicy bit: Wrangler’s “redirected configuration”

Run wrangler deploy against this repo and Wrangler will cheerfully tell you:

Using redirected Wrangler configuration.
Configuration being used: dist/server/wrangler.json

Your hand-written wrangler.toml is still “the original user’s configuration,” but the effective deploy config is the JSON the Astro adapter emits. That file is great for Astro — and it is wrong for three things you probably assumed were covered by the TOML at the repo root:

  1. main defaults to entry.mjs (Astro’s fetch handler only). Your cron handler lives in a generated cloudflare-worker.mjs that imports Astro and the cron bundle — but if nobody sets main, scheduled never runs. The dashboard shows Cron Triggers; every tick errors in microseconds because the runtime is invoking a Worker that does not export scheduled. Ouch.

  2. kv_namespaces shows up empty in that JSON. Your KV binding in wrangler.toml does not magically merge into the redirected file. Deploy “succeeds”; the Worker has no KV at runtime.

  3. triggers.crons is empty unless you patch it in. So you can stare at [triggers] in wrangler.toml all day; production cron config may still be {}.

The fix is a small post-build script that rewrites dist/server/wrangler.json after astro build: set main, inject KV id, merge cron expressions from wrangler.toml. The important lines look like this (trimmed from the real script):

const path = resolve('dist/server/wrangler.json')
const j = JSON.parse(readFileSync(path, 'utf8'))
j.main = 'cloudflare-worker.mjs'
j.kv_namespaces = [
  { binding: 'KV_STATUS_PAGE', id, preview_id: id },
]
j.triggers = { ...(j.triggers || {}), crons: cronsFromWranglerToml() }
writeFileSync(path, JSON.stringify(j, null, 2))

CI sets KV_NAMESPACE_ID from the Cloudflare API after ensuring the namespace exists, then runs this script before wrangler deploy. That is not cargo-cult configuration — it is the difference between “pretty dashboard” and “actually works.”

What actually runs at the edge

After npm run build, a script writes the Worker entry that Cloudflare should load as main:

import astro from './entry.mjs';
import { processCronTrigger } from './cron-bundle.mjs';

export default {
  async fetch(request, env, ctx) {
    return astro.fetch(request, env, ctx);
  },
  async scheduled(event, env, ctx) {
    return processCronTrigger(event, env, ctx);
  },
};

Two details that matter for correctness, not trivia:

  • ctx, not event, for waitUntil. In module Workers, waitUntil lives on the execution context (third argument). Notifications use ctx.waitUntil(...). If you use event.waitUntil, you get undefined and the cron row goes red the moment a Slack webhook fires.
  • Notification promises are wrapped so a bad webhook does not fail the whole cron tick — Cloudflare’s cron UI treats a rejected waitUntil promise as a failed run.

Your root wrangler.toml still describes intent (assets, cron schedule, production worker name). For example:

name = "cf-workers-status-page"
main = "dist/server/cloudflare-worker.mjs"
compatibility_flags = ["nodejs_compat"]

[assets]
binding = "ASSETS"
directory = "./dist/client"

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

[env.production]
name = "cf-workers-status-page"

The inject step reconciles that intent with what the adapter wrote to JSON. Think of it as a merge commit between “what Astro built” and “what operations need.”

The monitoring layer (what you are really running)

Conceptually: define monitors, run checks on a schedule, persist state and notify humans.

Probes — timeouts with AbortSignal, optional retries, expectStatus as one code or a list, optional responseContains / responseNotContains, headers, maxResponseTimeMs for a degraded state. Monitors are checked in parallel each cron tick; watch subrequest limits if you go wild.

State — KV holds the rolling history; the UI reads what cron writes.

Alerts — Slack, Telegram, Discord; alertAfterConsecutiveFailures (global or per monitor) so you do not wake the channel for a single flaky packet.

Config is YAML at the repo root; build emits JSON the Worker imports. A minimal monitor looks like:

settings:
  url: "https://status.example.com"
  defaultTimeoutMs: 25000
  alertAfterConsecutiveFailures: 2

monitors:
  - id: api-prod
    name: Public API
    url: "https://api.example.com/health"
    method: GET
    expectStatus: [200, 204]
    retries: 2
    retryDelayMs: 400

The fork’s UI goes beyond a traffic light: fleet-style views, latency context, probe age, fail streaks — the stuff you want when someone asks “was that a blip or a trend?”

Full field lists and labels live in the repo README.

Fork and deploy for your infrastructure

1. Fork or clone AndreaTrendafilov/cf-workers-status-page-1.

2. Edit config.yaml — especially settings.url (must match your public status URL). scripts/emit-config.mjs regenerates src/generated/config.json on dev/build; do not hand-edit that JSON.

3. Cloudflare: Workers + KV. The GitHub workflow can create/list the KV_STATUS_PAGE namespace and feed its id into the inject step.

4. Secrets for Actions (minimum):

  • CF_API_TOKEN, CF_ACCOUNT_ID
  • Optional: SECRET_* for Slack / Discord / Telegram so the workflow runs wrangler secret put after deploy.

Push main — build, inject, deploy, secrets, KV GC.

5. Attach your custom hostname to the Worker in the dashboard (or Terraform, or tears — your stack, your rules).

6. Local deploy, if you insist:

npm ci
npm run build
# Same inject CI runs, with KV_NAMESPACE_ID from your account:
KV_NAMESPACE_ID=your_namespace_id node scripts/inject-kv-binding.mjs
npx wrangler deploy --env production

If you skip the inject step locally, you get the same failure modes as before: no KV, no cron, or wrong entry — only faster because you did it to yourself on purpose.

Closing

This is not a reinvention of monitoring. It is edge-native uptime with a UI you can maintain without feeling like you are debugging a framework museum.

If you want history, read the upstream project. If you want Astro + Workers with the sharp edges filed down in my fork, open an issue on cf-workers-status-page-1.

Elsewhere: tikvi-assets next to the Tikvite homelab stack, and tst-rancher-charts for Helm-shaped day-job scars — same preference for small, inspectable repos.