<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Christian</title>
    <description>The latest articles on DEV Community by Christian (@christian_393c75a99).</description>
    <link>https://dev.to/christian_393c75a99</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4024790%2Fcd060187-ac88-4f41-8056-5b0f49ae47d7.png</url>
      <title>DEV Community: Christian</title>
      <link>https://dev.to/christian_393c75a99</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/christian_393c75a99"/>
    <language>en</language>
    <item>
      <title>Running a 120-site uptime monitor for $0/month on Cloudflare's free tier</title>
      <dc:creator>Christian</dc:creator>
      <pubDate>Sat, 11 Jul 2026 06:33:02 +0000</pubDate>
      <link>https://dev.to/christian_393c75a99/running-a-120-site-uptime-monitor-for-0month-on-cloudflares-free-tier-3h2</link>
      <guid>https://dev.to/christian_393c75a99/running-a-120-site-uptime-monitor-for-0month-on-cloudflares-free-tier-3h2</guid>
      <description>&lt;p&gt;I built a tool that answers one question: when a website won't load, is it your connection or the site? It runs two checks in parallel — measures your own line in the browser (latency + a 1 MiB download), and probes the target from Cloudflare's edge — then returns one of four verdicts: it's not you, it's you, it's both, or all clear. Demo: &lt;a href="https://itsnotyou.site" rel="noopener noreferrer"&gt;https://itsnotyou.site&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The interesting part isn't the tool, it's that the whole thing — app, ~120 monitored sites with 24h history, SSR status pages, share cards, outage alerts — runs for $0/month plus domains.&lt;/p&gt;

&lt;p&gt;The one thing that wanted to cost money&lt;/p&gt;

&lt;p&gt;Everything fit the Workers free plan except a background monitor probing ~120 sites on a tight cadence. As a Worker cron that's ~120 subrequests/run and, done naively, thousands of KV writes/day — which pushes you onto Workers Paid. The real ceiling is KV: 1,000 writes/day.&lt;/p&gt;

&lt;p&gt;So I split it. The user-facing test stays on Cloudflare — the edge probe still measures from the colo nearest the user, which is the point of edge. But the background sweep moved to a cheap VPS I already had: it probes the roster on a systemd timer and pushes results back into KV over the REST API.&lt;/p&gt;

&lt;p&gt;Staying under 1,000 KV writes/day&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;One KV key, not key-per-site. All ~120 statuses live in a single blob. Key-per-site at sweep cadence would be millions of writes/month; a single blob is one write per sweep.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Widen the cadence. I started at 2 min = 720 writes/day. Cloudflare emailed that I'd hit 50% of the daily limit (the sweep wasn't the only writer). I went to 3 min = 480/day, leaving headroom for share cards and the notify list.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Move the hot counter off KV. The anonymous "tests today" counter was the sneaky risk — a traffic spike could exhaust the write budget and stall the status sweep, the one thing you can't let happen. It went to Analytics Engine instead (free, uncapped, separate budget). Now no amount of user traffic can starve the monitor.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Reader and writer share code so their rules never drift: the streak/history folding and the up/slow/down decision are pure modules that run under both the Workers runtime and Node on the VPS. Keeping them free of Worker-only and Node-only APIs was a mild constraint that paid off — a public status word can never disagree with what the sweeper wrote.&lt;/p&gt;

&lt;p&gt;Other free-tier tricks&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Single Worker + static assets, not Pages + two Workers. Anything matching a file in ./public is served statically and never wakes the Worker.&lt;/li&gt;
&lt;li&gt;Rate limiting is in-memory per colo (a speed bump — KV-backed limiting costs a write per request). The real wall is one free zone Rate Limiting rule on /api/*.&lt;/li&gt;
&lt;li&gt;The one cron I kept is cheap: a 5-min outage watch that reads the blob and pings Telegram when &amp;gt;20% of the roster is down (with hysteresis). 1 KV read + 1 fetch per tick.&lt;/li&gt;
&lt;li&gt;Status pages are SSR and edge-cached, so search traffic mostly never reaches the Worker.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The SSRF footgun&lt;/p&gt;

&lt;p&gt;A "type any URL and we'll fetch it from our edge" endpoint is an SSRF invitation. The probe does strict hostname validation, allows https:443 only, follows at most two redirects and re-validates at every hop (a redirect to &lt;a href="http://169.254.169.254/" rel="noopener noreferrer"&gt;http://169.254.169.254/&lt;/a&gt; is the classic move), and caps at an 8s timeout.&lt;/p&gt;

&lt;p&gt;Honest tradeoffs&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The VPS is a real dependency. "$0 on Cloudflare" is true, but the sweep runs on a box I pay for elsewhere (spare capacity, so marginal cost was zero — YMMV). A cold-standby second box exists; the one rule is never run both sweepers at once or you double the writes.&lt;/li&gt;
&lt;li&gt;Free-tier KV writes are a budget you actively manage. The 50%-usage email was a useful warning; otherwise I'd have found out via the sweep silently failing to push.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stack: a Cloudflare Worker (no framework), vanilla single-file HTML/CSS/JS, KV for the status blob, Analytics Engine for counters, a Node script on a VPS on a systemd timer. Happy to go deeper.&lt;/p&gt;

&lt;p&gt;Demo: &lt;a href="https://itsnotyou.site" rel="noopener noreferrer"&gt;https://itsnotyou.site&lt;/a&gt;&lt;/p&gt;

</description>
      <category>serverless</category>
      <category>webdev</category>
      <category>reviews</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
