<?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: HowardZlh</title>
    <description>The latest articles on DEV Community by HowardZlh (@howardzlh).</description>
    <link>https://dev.to/howardzlh</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%2F4120707%2F25399bb1-b643-4752-b1d1-051e41453cc7.png</url>
      <title>DEV Community: HowardZlh</title>
      <link>https://dev.to/howardzlh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/howardzlh"/>
    <language>en</language>
    <item>
      <title>Wrangler authentication error 10000 with a per-Worker API token</title>
      <dc:creator>HowardZlh</dc:creator>
      <pubDate>Mon, 12 Oct 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/howardzlh/wrangler-authentication-error-10000-with-a-per-worker-api-token-3416</link>
      <guid>https://dev.to/howardzlh/wrangler-authentication-error-10000-with-a-per-worker-api-token-3416</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://guushu.com/notes/wrangler-error-10000-per-worker-token/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-wrangler-error-10000-per-worker-token" rel="noopener noreferrer"&gt;guushu.com/notes&lt;/a&gt;. I keep the original updated, so this copy may lag.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Between Sep 15 and Sep 18, 2026, at least seven Cloudflare Community threads had the same shape. An account-owned token had Individual Workers, Editor on one existing Worker. &lt;code&gt;tokens/verify&lt;/code&gt; said active. Then &lt;code&gt;wrangler deploy&lt;/code&gt; stopped on its first API call with Wrangler authentication error 10000.&lt;/p&gt;

&lt;p&gt;I answered one of those threads on Sep 18. That afternoon a Cloudflare staff member posted that they'd fixed an issue behind it. The platform bug is gone, but the same error still shows up for reasons that are your config, and the checks below tell the two apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened between Sep 15 and Sep 18
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://developers.cloudflare.com/workers/authorization/workers/" rel="noopener noreferrer"&gt;Workers roles and permissions&lt;/a&gt; page, which introduced per-Worker roles, was updated on Sep 15. Its Wrangler table says deploying an existing Worker needs Editor for that Worker, nothing more. People built tokens exactly that way and hit 403 with code 10000.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;When (UTC)&lt;/th&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sep 15&lt;/td&gt;
&lt;td&gt;Per-Worker roles documented. The first threads with 403 / 10000 appear&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 17, 09:25&lt;/td&gt;
&lt;td&gt;Staff reply in &lt;a href="https://community.cloudflare.com/t/individual-worker-editor-token-rejected-with-error-10000/959709" rel="noopener noreferrer"&gt;Individual Worker Editor token rejected with error 10000&lt;/a&gt;: can't reproduce, and posts a successful response from a per-Worker Editor token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 18, 14:50&lt;/td&gt;
&lt;td&gt;Same thread: "We've fixed an issue that I believe affected this request"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your token dates from that week and still fails, re-run the same request once before touching the token. If it now works, you were caught by that bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does tokens/verify say active when the deploy fails?
&lt;/h2&gt;

&lt;p&gt;Because verify doesn't check permissions. Cloudflare's &lt;a href="https://developers.cloudflare.com/fundamentals/api/get-started/create-token/" rel="noopener noreferrer"&gt;create token&lt;/a&gt; guide says the endpoint fetches the current status of the token. That tells you it exists and isn't expired or revoked. A 200 there and a 403 on a Worker aren't a contradiction, and verify can't tell you whether a scope is right.&lt;/p&gt;

&lt;p&gt;There's also a second trap: there are two verify endpoints. User-owned tokens use &lt;code&gt;/user/tokens/verify&lt;/code&gt;, and account-owned tokens use &lt;code&gt;/accounts/&amp;lt;id&amp;gt;/tokens/verify&lt;/code&gt;. I tried both with a user-owned token I keep for analytics reads. The first returned &lt;code&gt;"status": "active"&lt;/code&gt;. The second returned &lt;code&gt;1000 Invalid API Token&lt;/code&gt; for the same token, same account.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first API call wrangler deploy makes
&lt;/h2&gt;

&lt;p&gt;In the workers-sdk source, &lt;code&gt;validate-worker-props.ts&lt;/code&gt; reads the Worker's metadata from &lt;code&gt;/accounts/&amp;lt;id&amp;gt;/workers/services/&amp;lt;name&amp;gt;&lt;/code&gt; before it uploads anything. That's where the threads failed, so it's the request to reproduce by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.cloudflare.com/client/v4/accounts/&lt;/span&gt;&lt;span class="nv"&gt;$ACCOUNT_ID&lt;/span&gt;&lt;span class="s2"&gt;/workers/services/&lt;/span&gt;&lt;span class="nv"&gt;$WORKER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$CF_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="s1"&gt;'{success, errors}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a working per-Worker Editor token this returns &lt;code&gt;"success": true&lt;/code&gt; and the Worker's metadata. With code 10000 it's worth comparing against the successful response posted in the staff reply before rebuilding the token again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The --env suffix: a name the token was never given
&lt;/h2&gt;

&lt;p&gt;A per-Worker role is granted to a Worker name, and &lt;code&gt;wrangler deploy --env production&lt;/code&gt; doesn't deploy the name at the top of your config. It deploys &lt;code&gt;&amp;lt;name&amp;gt;-production&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My own home page Worker is &lt;code&gt;app-home&lt;/code&gt; in &lt;code&gt;wrangler.jsonc&lt;/code&gt; and deploys with &lt;code&gt;--env production&lt;/code&gt;. Running the curl above on 2026-09-30 with a token that has no Workers permission at all gave three different answers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Name requested&lt;/th&gt;
&lt;th&gt;Response&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app-home&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10090 This Worker does not exist on this account.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app-home-production&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;No access to the specified service.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A made-up name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10090 This Worker does not exist on this account.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So "does not exist" can mean you asked for the unsuffixed name, and "no access" means the Worker exists but your token isn't allowed near it. If you scoped the token in the dashboard by picking the Worker from a list, check that you picked the suffixed one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four things a per-Worker Editor token still can't do
&lt;/h2&gt;

&lt;p&gt;The same docs page lists the gaps. They're the reasons a correctly scoped token can still fail on the day the platform is fine:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;What it needs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Create a Worker that doesn't exist yet&lt;/td&gt;
&lt;td&gt;Product-level Admin. Per-Worker roles can't apply to a Worker that isn't there&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add, change or remove Routes or Custom Domains&lt;/td&gt;
&lt;td&gt;Editor on the Worker plus Zone, Workers Routes, Write on every affected zone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom Domains with per-Worker roles&lt;/td&gt;
&lt;td&gt;Not supported yet. The Limitations section says support is planned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query D1, read KV keys, list R2 objects directly&lt;/td&gt;
&lt;td&gt;Permissions on that resource. Deploying with bindings doesn't need them&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second row catches deploys that look harmless. If your wrangler config lists &lt;code&gt;routes&lt;/code&gt; or a &lt;code&gt;custom_domain&lt;/code&gt;, a deploy that changes them needs the zone permission too. The docs say later deploys only need Editor, as long as they don't add, update or remove that connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal CI token for one Worker
&lt;/h2&gt;

&lt;p&gt;For a deploy-only pipeline I'd build the token from the table up. Start with Individual Workers, Editor on the exact deployed name, suffix included. Add Zone, Workers Routes, Write on the zone only if the pipeline changes routes. Add nothing for bindings unless a step queries D1 or KV directly.&lt;/p&gt;

&lt;p&gt;My own home page deploy token is older than per-Worker roles and has Workers Routes and DNS edit on the zone, because its config binds two Custom Domains. I can't shrink it to one Worker yet, and that's the third row of the table in practice. Scoped tokens with an expiry are also item 11 in my &lt;a href="https://guushu.com/notes/cloudflare-zone-security-checklist/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-wrangler-error-10000-per-worker-token" rel="noopener noreferrer"&gt;Cloudflare zone security checklist&lt;/a&gt;, which has the read-only command for listing the tokens you already have.&lt;/p&gt;

&lt;p&gt;The usage collector I run needs one Account Analytics Read token and nothing else. The hosted version is a one-question form today, not a product: &lt;a href="https://guard.guushu.com/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-wrangler-error-10000-per-worker-token" rel="noopener noreferrer"&gt;guard.guushu.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Original, with any later corrections: &lt;a href="https://guushu.com/notes/wrangler-error-10000-per-worker-token/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-wrangler-error-10000-per-worker-token" rel="noopener noreferrer"&gt;guushu.com/notes/wrangler-error-10000-per-worker-token/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>serverless</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Cloudflare Email Sending activity log shows only failures: 3 checks</title>
      <dc:creator>HowardZlh</dc:creator>
      <pubDate>Sun, 11 Oct 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/howardzlh/cloudflare-email-sending-activity-log-shows-only-failures-3-checks-1no9</link>
      <guid>https://dev.to/howardzlh/cloudflare-email-sending-activity-log-shows-only-failures-3-checks-1no9</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://guushu.com/notes/cloudflare-email-sending-activity-log/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-sending-activity-log" rel="noopener noreferrer"&gt;guushu.com/notes&lt;/a&gt;. I keep the original updated, so this copy may lag.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A Worker sent mail through Cloudflare Email Service, the messages were arriving, and the sender had checked the raw sources to be sure. The Cloudflare Email Sending activity log still listed only failures, and Analytics showed 0 successes and a 100% failure rate. I answered that thread on Cloudflare Community on Sep 24.&lt;/p&gt;

&lt;p&gt;You don't have to take the dashboard's word for it, or anyone's guess. The Activity log reads a GraphQL dataset you can query yourself, and the answer tells you which of three problems you have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does the Activity log have its own data?
&lt;/h2&gt;

&lt;p&gt;No. The &lt;a href="https://developers.cloudflare.com/email-service/observability/logs/" rel="noopener noreferrer"&gt;Email Service logs&lt;/a&gt; page says the Activity log surfaces the same per-event data as the &lt;code&gt;emailSendingAdaptive&lt;/code&gt; and &lt;code&gt;emailRoutingAdaptive&lt;/code&gt; GraphQL datasets. The analytics page says the dashboard charts are queried from the GraphQL Analytics API. If successful sends were recorded, a direct query finds them.&lt;/p&gt;

&lt;p&gt;That turns a vague "the dashboard looks wrong" into a yes or no question. Either the rows exist and the dashboard is dropping them, or they were never written for the zone you're looking at.&lt;/p&gt;

&lt;h2&gt;
  
  
  The GraphQL query, by status and sendingDomain
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://developers.cloudflare.com/email-service/observability/metrics-analytics/" rel="noopener noreferrer"&gt;Email Service metrics and analytics&lt;/a&gt; page has a ready-made query called Email sending operations. It groups by &lt;code&gt;date&lt;/code&gt; and &lt;code&gt;status&lt;/code&gt;. I add &lt;code&gt;sendingDomain&lt;/code&gt;, which is one of the documented dimensions and the one that matters here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;EmailSendingByStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$zoneTag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;!,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;!,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;!)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;viewer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;zones&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;zoneTag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$zoneTag&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;emailSendingAdaptiveGroups&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;date_geq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;date_leq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$end&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;date_DESC&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;dimensions&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;sendingDomain&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details from the same page that trip people up. The token needs Analytics Read. &lt;code&gt;zoneTag&lt;/code&gt; is the zone ID, not the account ID, because these are zone-level datasets. Data is kept for 31 days, so a date range older than that returns nothing.&lt;/p&gt;

&lt;p&gt;To run it from a terminal, save the query as &lt;code&gt;q.graphql&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;jq &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;--rawfile&lt;/span&gt; q q.graphql &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--arg&lt;/span&gt; z &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ZONE_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--arg&lt;/span&gt; s 2026-09-01 &lt;span class="nt"&gt;--arg&lt;/span&gt; e 2026-09-30 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'{query: $q, variables: {zoneTag: $z, start: $s, end: $e}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
| curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://api.cloudflare.com/client/v4/graphql &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$CF_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; @- &lt;span class="se"&gt;\&lt;/span&gt;
| jq &lt;span class="s1"&gt;'.data.viewer.zones[0].emailSendingAdaptiveGroups'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Reading the result: rows, an empty list, or only failures
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;delivered&lt;/code&gt; rows come back. The data exists and the dashboard isn't showing it. That's a clean report for support: the query, the output, and a screenshot of the log for the same range.&lt;/p&gt;

&lt;p&gt;An empty list comes back. That isn't an error, and it doesn't prove the dashboard is broken. When I ran this on my own zone for September, &lt;code&gt;emailSendingAdaptiveGroups&lt;/code&gt; returned &lt;code&gt;[]&lt;/code&gt;, because I don't send through Email Service. The routing version of the same query returned 30 rows from the same zone. Empty means nothing was recorded under this zone ID.&lt;/p&gt;

&lt;p&gt;Only failure statuses come back. Then the log is probably right and the mail you saw arrive went some other way, or through another zone. Keep reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is the From domain on a different zone?
&lt;/h2&gt;

&lt;p&gt;It's the first thing I'd check when the list is empty or short. These datasets are per zone, so a Worker whose From address is on another zone should log its successes under that zone. The docs don't say this in one sentence, so treat it as my inference from the zone-level design, not a documented rule.&lt;/p&gt;

&lt;p&gt;The cheap test is in the dashboard. Go to Compute, Email Service, Email Sending, and pick the account-wide view instead of one domain before opening Analytics. If the missing sends show up there, the zone was the problem, not the log.&lt;/p&gt;

&lt;h2&gt;
  
  
  Delivery failed and Failed are different problems
&lt;/h2&gt;

&lt;p&gt;Expand one failure before changing anything. The logs page lists five sending statuses, and two of them sound alike:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Meaning per the docs&lt;/th&gt;
&lt;th&gt;Where to look&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sent&lt;/td&gt;
&lt;td&gt;Accepted and queued&lt;/td&gt;
&lt;td&gt;Wait, or check for a later event&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delivered&lt;/td&gt;
&lt;td&gt;Recipient server accepted it&lt;/td&gt;
&lt;td&gt;Nothing to fix&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delivery failed&lt;/td&gt;
&lt;td&gt;Bounced, hard or soft&lt;/td&gt;
&lt;td&gt;Recipient address, their mail server response&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rejected&lt;/td&gt;
&lt;td&gt;Recipient is on your suppression list&lt;/td&gt;
&lt;td&gt;Suppressions page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failed&lt;/td&gt;
&lt;td&gt;Configuration or authentication problem&lt;/td&gt;
&lt;td&gt;Sending domain setup, DNS, the Worker binding&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A log full of Delivery failed while mail is landing would be odd. A log full of Failed points at the sending domain's own setup. The docs also have a Delivery failure analysis query that groups by &lt;code&gt;errorCause&lt;/code&gt; and &lt;code&gt;sendingDomain&lt;/code&gt;, and the per-event &lt;code&gt;emailSendingAdaptive&lt;/code&gt; dataset adds &lt;code&gt;errorDetail&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd hand support
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;delivered&lt;/code&gt; rows exist and the dashboard shows 100% failed, the sender is right that something is off on Cloudflare's side. The report that gets a quick answer is short: the zone ID, the date range, the GraphQL output with &lt;code&gt;status&lt;/code&gt; and &lt;code&gt;sendingDomain&lt;/code&gt;, and the Activity log screenshot for the same range.&lt;/p&gt;

&lt;p&gt;The receiving side works the same way. Its dataset is &lt;code&gt;emailRoutingAdaptiveGroups&lt;/code&gt;, and a blank routing log has its own diagnosis, which I wrote up in &lt;a href="https://guushu.com/notes/cloudflare-email-routing-stopped-working/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-sending-activity-log" rel="noopener noreferrer"&gt;Cloudflare Email Routing stopped working&lt;/a&gt;. If you're only receiving, the &lt;a href="https://guushu.com/notes/cloudflare-email-routing-support-address/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-sending-activity-log" rel="noopener noreferrer"&gt;Cloudflare Email Routing support address&lt;/a&gt; setup is the simpler place to start.&lt;/p&gt;

&lt;p&gt;The shared inbox I'm sketching sits on the receiving side of Email Service, one support@ that two people can answer. It's a waitlist page today, not a product: &lt;a href="https://guard.guushu.com/inbox?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-sending-activity-log" rel="noopener noreferrer"&gt;guard.guushu.com/inbox&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Original, with any later corrections: &lt;a href="https://guushu.com/notes/cloudflare-email-sending-activity-log/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-sending-activity-log" rel="noopener noreferrer"&gt;guushu.com/notes/cloudflare-email-sending-activity-log/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>email</category>
      <category>serverless</category>
      <category>devops</category>
    </item>
    <item>
      <title>Animated GitHub contribution snake for your profile README, your repos, and your orgs</title>
      <dc:creator>HowardZlh</dc:creator>
      <pubDate>Sat, 10 Oct 2026 13:15:00 +0000</pubDate>
      <link>https://dev.to/howardzlh/animated-github-contribution-snake-for-your-profile-readme-your-repos-and-your-orgs-387m</link>
      <guid>https://dev.to/howardzlh/animated-github-contribution-snake-for-your-profile-readme-your-repos-and-your-orgs-387m</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnyo4tue8725zvn4qz6y2.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnyo4tue8725zvn4qz6y2.gif" alt="A snake eating a year of daily contributions on a GitHub-style grid, heading for the nearest green square each time" width="799" height="174"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;GitHub keeps a contribution calendar for user accounts and nothing else. A repository doesn't have one, and neither does an organization. That's why every contribution snake you've seen sits on somebody's profile page.&lt;/p&gt;

&lt;p&gt;I wanted the snake on two pages that don't qualify: the README of &lt;a href="https://github.com/HowardZlh/stellar-odyssey" rel="noopener noreferrer"&gt;stellar-odyssey&lt;/a&gt;, and the profile of the &lt;a href="https://github.com/FailRouter" rel="noopener noreferrer"&gt;FailRouter&lt;/a&gt; organization. So I wrote &lt;a href="https://github.com/HowardZlh/github-action-shares" rel="noopener noreferrer"&gt;github-action-shares&lt;/a&gt;, a GitHub Action that builds the calendar itself and draws three animated SVGs from it. It took two days, from v1.0.0 to v1.2.0.&lt;/p&gt;

&lt;h2&gt;
  
  
  What lands in your README
&lt;/h2&gt;

&lt;p&gt;One run writes eight SVGs (a light and a dark version of each) and a &lt;code&gt;stats.json&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;What it shows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;snake.svg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a snake that eats the year, nearest square first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;heatmap.svg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the familiar grid, filling in week by week&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;skyline.svg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a 3D isometric skyline with the busiest day and the longest streak&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;activity.svg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the snake, or a one-line summary while the project is young&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stats.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;total, busiest day, streaks, first and last active day&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The heatmap and the skyline are drawn from the same year as the snake at the top of this post:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8kcm6fdmikqfse9jru29.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8kcm6fdmikqfse9jru29.gif" alt="Heatmap of a year of contributions, squares popping in from left to right" width="800" height="216"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu7rarpq43qw0grh2w3d4.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu7rarpq43qw0grh2w3d4.gif" alt="3D isometric skyline of the same year, bars growing from the floor, with busiest day 68 and longest streak 20 days" width="700" height="495"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All three draw the same daily numbers. Pick one per page. Three charts of one dataset make a reader scroll past the same year three times.&lt;/p&gt;

&lt;h2&gt;
  
  
  A young repo doesn't show a year of empty squares
&lt;/h2&gt;

&lt;p&gt;This was the first thing that looked wrong. A repository that is two months old, drawn on GitHub's 53-week grid, is forty-five weeks of grey followed by a few green columns. To a visitor that reads as "barely touched", which is the opposite of what a project activity chart is for.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;window: auto&lt;/code&gt; starts the grid at the week of the first commit, at least eight weeks wide. Stellar Odyssey started on 2026-07-19, so its grid is twelve weeks, not fifty-three:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe2qjpzzvk2dpv2ovyueo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe2qjpzzvk2dpv2ovyueo.png" alt="The Project Activity section of the stellar-odyssey README: 304 commits since 2026-07-19, a snake moving through a twelve-week grid" width="800" height="236"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Below ten active days even a cropped grid is mostly empty, so &lt;code&gt;activity.svg&lt;/code&gt; turns into a sentence. The FailRouter org page shows that state right now:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqklfro3ln49o46uj5com.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqklfro3ln49o46uj5com.png" alt="FailRouter organization profile: a repository table with a Star button, a one-line commit summary reading 24 commits since 2026-08-16, 4 active days, and a recent activity list" width="800" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On its tenth active day the same URL starts serving the snake. Nobody edits the README for that, and no bot commits to it either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Set it up for a repository in three steps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Add the workflow.&lt;/strong&gt; Save this as &lt;code&gt;.github/workflows/readme-showcase.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;README showcase&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt; &lt;span class="nv"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;23&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt; &lt;span class="pi"&gt;}]&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;art&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v5&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;0&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;# git log needs the full history&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HowardZlh/github-action-shares@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;repo&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;git-path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;.&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;window&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;auto&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;out-dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;dist&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;crazy-max/ghaction-github-pages@v5&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;target_branch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;output&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;build_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;dist&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;keep_history&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;false&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;GITHUB_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;secrets.GITHUB_TOKEN&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Run it once.&lt;/strong&gt; Open the Actions tab, pick the workflow, and press Run workflow. After that the nightly schedule takes over.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F00qzf8b6p9i2m7exf84s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F00qzf8b6p9i2m7exf84s.png" alt="The Actions tab of a repository with the Showcase workflow selected and the Run workflow dropdown open on branch main" width="800" height="297"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The run takes 10 to 15 seconds and creates an &lt;code&gt;output&lt;/code&gt; branch with the images:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frehlno2x3m49xsxgvxxz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frehlno2x3m49xsxgvxxz.png" alt="The output branch of the repository: one bot commit with the SVGs (activity, heatmap, skyline, snake) in light and dark, plus stats.json" width="800" height="460"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Point your README at it.&lt;/strong&gt; Replace &lt;code&gt;OWNER/REPO&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;picture&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;source&lt;/span&gt; &lt;span class="na"&gt;media=&lt;/span&gt;&lt;span class="s"&gt;"(prefers-color-scheme: dark)"&lt;/span&gt; &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"https://raw.githubusercontent.com/OWNER/REPO/output/activity-dark.svg"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"OWNER/REPO commits since the first one"&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://raw.githubusercontent.com/OWNER/REPO/output/activity.svg"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/picture&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; picks the dark file for visitors on GitHub's dark theme. Write a real &lt;code&gt;alt&lt;/code&gt;; screen readers read it and image search indexes it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Your profile and your organization
&lt;/h2&gt;

&lt;p&gt;On a profile, &lt;code&gt;source: user&lt;/code&gt; reads your contribution calendar through GraphQL, so private work counts too if you've ticked &lt;em&gt;Private contributions&lt;/em&gt; in your profile settings. The profile example pairs the snake with &lt;a href="https://github.com/yoshi389111/github-profile-3d-contrib" rel="noopener noreferrer"&gt;github-profile-3d-contrib&lt;/a&gt; for the 3D calendar, and adds three &lt;a href="https://shields.io" rel="noopener noreferrer"&gt;shields.io&lt;/a&gt; badges that read their numbers from &lt;code&gt;stats.json&lt;/code&gt;. Here is &lt;a href="https://github.com/HowardZlh" rel="noopener noreferrer"&gt;my profile&lt;/a&gt; in dark mode:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn6f1twv7mewr7f9fsito.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn6f1twv7mewr7f9fsito.png" alt="HowardZlh's GitHub profile in dark mode: the snake eating 1,208 contributions, a 3D contribution calendar with a language donut, and badges for busiest day, longest streak and active days" width="800" height="756"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For an organization, &lt;code&gt;source: org&lt;/code&gt; sums the commit statistics of every public, non-fork repository. The same action has a second command, &lt;code&gt;readme&lt;/code&gt;, that rewrites two marked blocks in a README: a project table sorted by stars and a recent-activity list. On an org page that list includes lines like "⭐ someone starred your-repo", which is social proof a personal profile can't show.&lt;/p&gt;

&lt;p&gt;You can copy both setups from &lt;a href="https://github.com/HowardZlh/github-action-shares/tree/main/examples" rel="noopener noreferrer"&gt;&lt;code&gt;examples/&lt;/code&gt;&lt;/a&gt;, and the &lt;a href="https://github.com/HowardZlh/github-action-shares/blob/main/docs/en/tutorial.md" rel="noopener noreferrer"&gt;tutorial&lt;/a&gt; walks through each one with the same screenshots.&lt;/p&gt;
&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8ufgnwbt0lo2tsnrq2qc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8ufgnwbt0lo2tsnrq2qc.png" alt="Data flow: git log, stats/commit_activity or GraphQL feed one daily calendar; the action renders eight SVGs and stats.json into the output branch; the README loads them through raw.githubusercontent.com" width="800" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three sources, one calendar.&lt;/strong&gt; A repository is counted from &lt;code&gt;git log&lt;/code&gt; dates in the checkout. An organization is the sum of &lt;code&gt;/repos/{repo}/stats/commit_activity&lt;/code&gt; across its public repos. A user comes from the GraphQL &lt;code&gt;contributionCalendar&lt;/code&gt;, the same numbers GitHub draws on the profile. Everything after that step only sees a list of &lt;code&gt;{ date, count }&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Animation without JavaScript.&lt;/strong&gt; GitHub serves README images through its camo proxy as plain &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tags. Scripts never run there, and hover events never fire. What does work is CSS &lt;code&gt;@keyframes&lt;/code&gt; and SMIL. The heatmap and the skyline use keyframes with a per-cell delay. The snake is a &lt;code&gt;&amp;lt;rect&amp;gt;&lt;/code&gt; per body segment moving along an &lt;code&gt;&amp;lt;animateMotion&amp;gt;&lt;/code&gt; path, and each square it eats has its own &lt;code&gt;&amp;lt;animate&amp;gt;&lt;/code&gt; that turns it grey at the exact step the head arrives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The snake takes the short way.&lt;/strong&gt; It enters at the top left, always heads for the nearest uneaten green square one cell at a time, and leaves through the nearer edge. The step length is chosen so a loop lasts between 8 and 30 seconds, whether the year had five active days or three hundred.&lt;/p&gt;

&lt;p&gt;My first version swept all 371 cells in order. On a sparse year it spent ten seconds crawling over grey before it reached anything green.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing lands on your default branch.&lt;/strong&gt; The images go to an &lt;code&gt;output&lt;/code&gt; branch through &lt;a href="https://github.com/crazy-max/ghaction-github-pages" rel="noopener noreferrer"&gt;crazy-max/ghaction-github-pages&lt;/a&gt; with &lt;code&gt;keep_history: false&lt;/code&gt;, so that branch is always one commit. If merging to &lt;code&gt;main&lt;/code&gt; deploys your site, a nightly art refresh never triggers it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quartile colours, like GitHub.&lt;/strong&gt; The four greens are cut at the quartiles of your non-zero days, not at a fraction of the maximum. One 68-commit day doesn't wash the rest of the year out to the palest shade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Respectful defaults.&lt;/strong&gt; Under &lt;code&gt;prefers-reduced-motion: reduce&lt;/code&gt; the SVGs show the final frame. Each one has &lt;code&gt;role="img"&lt;/code&gt;, a &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; with the real number and a &lt;code&gt;&amp;lt;desc&amp;gt;&lt;/code&gt; with the date range. A longest streak under three days is left out of the text, because "longest streak: 1 day" helps nobody.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero dependencies.&lt;/strong&gt; It's a composite action that runs &lt;code&gt;node bin/showcase.mjs&lt;/code&gt;: about 850 lines of JavaScript using the built-in &lt;code&gt;fetch&lt;/code&gt; and string templates. There's no &lt;code&gt;node_modules&lt;/code&gt; to audit and no Docker image to pull. Tests use &lt;code&gt;node:test&lt;/code&gt; against a throwaway git repository and a fake &lt;code&gt;fetch&lt;/code&gt;, and CI fails under 90% line coverage.&lt;/p&gt;
&lt;h2&gt;
  
  
  Tools it stands on
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Actions, composite action&lt;/td&gt;
&lt;td&gt;runs the generator on a schedule&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node.js built-ins (&lt;code&gt;fetch&lt;/code&gt;, &lt;code&gt;node:test&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;API calls, tests, coverage, no packages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub REST and GraphQL APIs&lt;/td&gt;
&lt;td&gt;commit statistics, contribution calendar, stars, events&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;crazy-max/ghaction-github-pages&lt;/td&gt;
&lt;td&gt;pushes the SVGs to the &lt;code&gt;output&lt;/code&gt; branch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;raw.githubusercontent.com&lt;/td&gt;
&lt;td&gt;serves the images to your README&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;shields.io dynamic JSON badges&lt;/td&gt;
&lt;td&gt;live numbers from &lt;code&gt;stats.json&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;github-profile-3d-contrib (optional)&lt;/td&gt;
&lt;td&gt;the 3D calendar on a profile&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  Things I ran into
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not every badge host gets through camo.&lt;/strong&gt; Visitor-counter images from komarev.com load in a browser and return &lt;code&gt;404 Cannot proxy the given URL&lt;/code&gt; inside a README. Copy the image address from the rendered page and &lt;code&gt;curl&lt;/code&gt; it before you rely on a badge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The org's own &lt;code&gt;.github&lt;/code&gt; repo broke the org chart.&lt;/strong&gt; Every commit to the profile README made GitHub recompute that repo's statistics, and the API kept answering &lt;code&gt;202 Accepted&lt;/code&gt;. The action now skips &lt;code&gt;.github&lt;/code&gt; and skips a repo whose statistics are still cold with a warning instead of failing the run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pull request events lost their titles.&lt;/strong&gt; Since 2025 the Events API ships pull request events without &lt;code&gt;title&lt;/code&gt; or &lt;code&gt;html_url&lt;/code&gt;, so the activity list fetches &lt;code&gt;/pulls/{n}&lt;/code&gt; for the few it shows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For comparison, here is how it differs from the snake most profiles use:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;a href="https://github.com/Platane/snk" rel="noopener noreferrer"&gt;Platane/snk&lt;/a&gt;&lt;/th&gt;
&lt;th&gt;github-action-shares&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Works for&lt;/td&gt;
&lt;td&gt;users&lt;/td&gt;
&lt;td&gt;users, repos, orgs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Eating order&lt;/td&gt;
&lt;td&gt;one colour level after another&lt;/td&gt;
&lt;td&gt;nearest square first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Loop on a busy year I measured&lt;/td&gt;
&lt;td&gt;about 85 s&lt;/td&gt;
&lt;td&gt;8 to 30 s by design&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;p&gt;README images can't be interactive. If you want tooltips or zoom, link the image to a page you host. The org chart only sees public repositories, and it inherits GitHub's 52-week window for commit statistics. GitHub pauses scheduled workflows in a public repository after 60 days without activity, so a dormant repo's chart stops refreshing until you re-enable it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Copy the workflow above into one of your repos, press Run workflow, and paste the five lines of &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; into the README. The whole thing takes about two minutes, most of it waiting for the first run.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/HowardZlh" rel="noopener noreferrer"&gt;
        HowardZlh
      &lt;/a&gt; / &lt;a href="https://github.com/HowardZlh/github-action-shares" rel="noopener noreferrer"&gt;
        github-action-shares
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Animated contribution heatmap, snake and 3D skyline SVGs for any GitHub repo, org or user, plus auto-updated stars and activity in your README. Zero-dependency GitHub Action, MIT.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Animated GitHub README stats for any user, organization or repository&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;English&lt;/strong&gt; | &lt;a href="https://github.com/HowardZlh/github-action-shares/./README.zh-CN.md" rel="noopener noreferrer"&gt;中文&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A GitHub Action that draws a contribution heatmap, a snake that eats it, and a 3D skyline, as animated SVGs, for &lt;strong&gt;a single repository or a whole organization&lt;/strong&gt; as well as for a user account. It also keeps a star-sorted project table and a recent-activity list in your README up to date. No dependencies, MIT.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/HowardZlh/github-action-shares/actions/workflows/ci.yml" rel="noopener noreferrer"&gt;&lt;img src="https://github.com/HowardZlh/github-action-shares/actions/workflows/ci.yml/badge.svg" alt="CI"&gt;&lt;/a&gt;
&lt;a href="https://github.com/HowardZlh/github-action-shares/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/b8cadaa967891081f8f165695470689986c028821dd8a040132f6e661795dc0d/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d626c7565" alt="License: MIT"&gt;&lt;/a&gt;
&lt;a href="https://github.com/HowardZlh/github-action-shares" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/dbff8f1e158aa1020376239f1f18108447a1c6d0b92e078a564b221f978142f2/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f537461722d6f6e5f4769744875622d77686974653f7374796c653d736f6369616c266c6f676f3d676974687562" alt="Star HowardZlh/github-action-shares on GitHub"&gt;&lt;/a&gt;&lt;/p&gt;

  
  &lt;img alt="Animated snake eating a year of @HowardZlh's daily contributions, heading for the nearest active day" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FHowardZlh%2Fgithub-action-shares%2Foutput%2Fdemo%2Fsnake.svg"&gt;

&lt;p&gt;That snake is eating a year of &lt;a href="https://github.com/HowardZlh" rel="noopener noreferrer"&gt;@HowardZlh&lt;/a&gt;'s contributions, redrawn every night by &lt;a href="https://github.com/HowardZlh/github-action-shares/.github/workflows/showcase.yml" rel="noopener noreferrer"&gt;&lt;code&gt;showcase.yml&lt;/code&gt;&lt;/a&gt;. A two-month-old repo gets a grid that starts at its first commit instead of a year of empty squares, and a one-line summary until it has ten active days.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Jump to&lt;/strong&gt;: &lt;a href="https://github.com/HowardZlh/github-action-shares#what-you-get" rel="noopener noreferrer"&gt;What you get&lt;/a&gt; · &lt;a href="https://github.com/HowardZlh/github-action-shares#on-real-pages" rel="noopener noreferrer"&gt;On real pages&lt;/a&gt; · &lt;a href="https://github.com/HowardZlh/github-action-shares#quick-start-for-a-repository" rel="noopener noreferrer"&gt;Quick start&lt;/a&gt; · &lt;a href="https://github.com/HowardZlh/github-action-shares#for-your-profile-readme" rel="noopener noreferrer"&gt;Profile&lt;/a&gt; · &lt;a href="https://github.com/HowardZlh/github-action-shares#for-an-organization-profile" rel="noopener noreferrer"&gt;Organization&lt;/a&gt; · &lt;a href="https://github.com/HowardZlh/github-action-shares#inputs" rel="noopener noreferrer"&gt;Inputs&lt;/a&gt; · &lt;a href="https://github.com/HowardZlh/github-action-shares/docs/en/tutorial.md" rel="noopener noreferrer"&gt;Tutorial&lt;/a&gt; · &lt;a href="https://github.com/HowardZlh/github-action-shares#how-it-works" rel="noopener noreferrer"&gt;How it works&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What you get&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Every SVG comes in a light and a dark…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/HowardZlh/github-action-shares" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;If you put it on a page, open a PR that adds your repo to the &lt;em&gt;Used by&lt;/em&gt; list. And if it saved you an evening, a star on the repo helps the next person find it.&lt;/p&gt;

</description>
      <category>github</category>
      <category>githubactions</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Cloudflare Pages not updating after deploy: the s-maxage trap</title>
      <dc:creator>HowardZlh</dc:creator>
      <pubDate>Sat, 10 Oct 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/howardzlh/cloudflare-pages-not-updating-after-deploy-the-s-maxage-trap-799</link>
      <guid>https://dev.to/howardzlh/cloudflare-pages-not-updating-after-deploy-the-s-maxage-trap-799</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://guushu.com/notes/cloudflare-pages-not-updating-after-deploy/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-pages-not-updating-after-deploy" rel="noopener noreferrer"&gt;guushu.com/notes&lt;/a&gt;. I keep the original updated, so this copy may lag.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A new Pages deploy showed Success and was marked Production, yet the custom domain and &lt;code&gt;&amp;lt;project&amp;gt;.pages.dev&lt;/code&gt; both kept serving the old &lt;code&gt;index.html&lt;/code&gt; for over a day. I answered that thread on Cloudflare Community on Sep 28. Cloudflare Pages not updating after deploy on both hostnames at once points at a header, not at the deploy.&lt;/p&gt;

&lt;p&gt;The headers in the post said it all: &lt;code&gt;cf-cache-status: HIT&lt;/code&gt;, an &lt;code&gt;age&lt;/code&gt; of about 30 hours, and &lt;code&gt;cache-control: public, s-maxage=604800&lt;/code&gt;. Two more redeploys with different content hashes didn't change a thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is Cloudflare Pages still serving the old version?
&lt;/h2&gt;

&lt;p&gt;Because something told the CDN it could keep that HTML for a week. The Pages default for a cacheable asset is &lt;code&gt;public, max-age=0, must-revalidate&lt;/code&gt;, and Pages never adds &lt;code&gt;s-maxage&lt;/code&gt; by itself. A &lt;code&gt;s-maxage=604800&lt;/code&gt; comes from the project: a &lt;code&gt;_headers&lt;/code&gt; rule, a Pages Function, or an advanced mode &lt;code&gt;_worker.js&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You can see the default on any Pages site that doesn't override it. I run two Pages projects, and on 2026-09-30 both returned the same thing on the custom domain and on &lt;code&gt;pages.dev&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://&amp;lt;project&amp;gt;.pages.dev/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s2"&gt;"cache-control|cf-cache-status|^age"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cache-control: public, max-age=0, must-revalidate
cf-cache-status: DYNAMIC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's what "no custom caching" looks like. The Headers section of the &lt;a href="https://developers.cloudflare.com/pages/configuration/serving-pages/" rel="noopener noreferrer"&gt;Serving Pages&lt;/a&gt; page lists it under headers sometimes added, for any request without an &lt;code&gt;Authorization&lt;/code&gt; or &lt;code&gt;Range&lt;/code&gt; header.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the s-maxage usually comes from
&lt;/h2&gt;

&lt;p&gt;The most likely source is a &lt;code&gt;_headers&lt;/code&gt; file in the build output with a catch-all rule, the kind that gets copied from a performance guide:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/*
  Cache-Control: public, s-maxage=604800
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That rule matches every path, HTML included. Search the build output as well as the source, because frameworks copy &lt;code&gt;public/&lt;/code&gt; into it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"s-maxage"&lt;/span&gt; dist/ public/ functions/ _worker.js 2&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://developers.cloudflare.com/pages/configuration/headers/" rel="noopener noreferrer"&gt;_headers&lt;/a&gt; page has a caution worth knowing here. Rules in &lt;code&gt;_headers&lt;/code&gt; don't apply to responses generated by Pages Functions. If an SSR framework or &lt;code&gt;_worker.js&lt;/code&gt; renders the page, the header is being set in that code's &lt;code&gt;Response&lt;/code&gt;, and the grep above will find it in &lt;code&gt;functions/&lt;/code&gt; or the worker file.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to purge the Pages cache without a zone
&lt;/h2&gt;

&lt;p&gt;You mostly can't, and that's the uncomfortable part. The Serving Pages page describes one purge path: go to your zone and use Caching, Configuration, Purge Everything. A project on &lt;code&gt;pages.dev&lt;/code&gt; with a custom domain on some other DNS provider has no zone in the account to purge.&lt;/p&gt;

&lt;p&gt;The same page's Asset retention section says assets are cached per data center with a one-week TTL, and after a new deploy the old ones can stay up to a week. That matches the thread. The redeploys didn't evict HTML cached under the project's own &lt;code&gt;s-maxage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the fix is two steps, and the order matters:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Remove the rule that sets &lt;code&gt;s-maxage&lt;/code&gt; on HTML and deploy. Otherwise every future deploy starts a fresh seven-day clock.&lt;/li&gt;
&lt;li&gt;Wait out the copies already cached. Worst case that's the week the header promised.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the custom domain is on a zone in your account, Purge Everything on that zone clears it there. The &lt;code&gt;pages.dev&lt;/code&gt; hostname isn't part of your zone, so it can stay stale until retention runs out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the new build on its deployment URL
&lt;/h2&gt;

&lt;p&gt;Every deployment also gets its own address. The &lt;a href="https://developers.cloudflare.com/pages/configuration/preview-deployments/" rel="noopener noreferrer"&gt;preview deployments&lt;/a&gt; page says it's hash-based, like &lt;code&gt;&amp;lt;hash&amp;gt;.&amp;lt;project&amp;gt;.pages.dev&lt;/code&gt;, and atomic, so it keeps pointing at that exact build. Open it from the deployments list in the dashboard.&lt;/p&gt;

&lt;p&gt;It's a different hostname, so it shouldn't share the cached HTML from the production hostnames. I haven't tested that part myself. It's still the quickest way to prove the new build is correct while you wait, instead of redeploying a third time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cache Rules on a custom domain cause the same symptom
&lt;/h2&gt;

&lt;p&gt;Even with clean headers, a custom domain can go stale if its zone caches HTML. The Serving Pages page says it plainly: adding caching to your custom domain may lead to stale assets after a deployment, and it can also break Pages redirects and Functions.&lt;/p&gt;

&lt;p&gt;If the custom domain sits on your zone, read its Cache Rules. Both of my Pages projects sit on custom domains in my own zone, and &lt;code&gt;DYNAMIC&lt;/code&gt; is what those custom domains return: the HTML isn't being held at the edge. The read below uses the same &lt;code&gt;cf()&lt;/code&gt; helper as my &lt;a href="https://guushu.com/notes/cloudflare-zone-security-checklist/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-pages-not-updating-after-deploy" rel="noopener noreferrer"&gt;Cloudflare zone security checklist&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cf /zones/&lt;span class="nv"&gt;$ZONE_ID&lt;/span&gt;/rulesets/phases/http_request_cache_settings/entrypoint &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'.result.rules[] | {description, expression, action_parameters}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A rule that sets an Edge TTL on everything, or on &lt;code&gt;/&lt;/code&gt;, is the same bug in a different place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Long cache headers that are safe on Pages
&lt;/h2&gt;

&lt;p&gt;Long caching is fine for files whose names change when their content does. The &lt;code&gt;_headers&lt;/code&gt; page's own example scopes it to one folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/static/*
  Cache-Control: public, max-age=31556952, immutable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put fingerprinted files there and leave HTML on the default. Bundlers like Vite and Astro already put hashes in asset names, so the only thing to avoid is a &lt;code&gt;/*&lt;/code&gt; rule. HTML is the file that has to change on every deploy, and it's the one that must never get a week-long shared cache.&lt;/p&gt;

&lt;p&gt;A purge button for Pages projects without a zone would be a fair feature request. I couldn't find one in the docs.&lt;/p&gt;

&lt;p&gt;Cache Rules and other zone settings that change quietly are what the daily drift check I'm sketching would diff. It's a one-question form today, not a product: &lt;a href="https://guard.guushu.com/zone-audit?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-pages-not-updating-after-deploy" rel="noopener noreferrer"&gt;guard.guushu.com/zone-audit&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Original, with any later corrections: &lt;a href="https://guushu.com/notes/cloudflare-pages-not-updating-after-deploy/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-pages-not-updating-after-deploy" rel="noopener noreferrer"&gt;guushu.com/notes/cloudflare-pages-not-updating-after-deploy/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>webdev</category>
      <category>caching</category>
      <category>devops</category>
    </item>
    <item>
      <title>Cloudflare Email Routing stopped working? Check nameservers first</title>
      <dc:creator>HowardZlh</dc:creator>
      <pubDate>Fri, 09 Oct 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/howardzlh/cloudflare-email-routing-stopped-working-check-nameservers-first-5dfa</link>
      <guid>https://dev.to/howardzlh/cloudflare-email-routing-stopped-working-check-nameservers-first-5dfa</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://guushu.com/notes/cloudflare-email-routing-stopped-working/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-routing-stopped-working" rel="noopener noreferrer"&gt;guushu.com/notes&lt;/a&gt;. I keep the original updated, so this copy may lag.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The dashboard said Enabled, DNS records said Locked, and all 49 routing rules were still there. When Cloudflare Email Routing stopped working for someone I was helping in September, the only honest signal was the Activity log. Its last row was three days old, and there was nothing after it.&lt;/p&gt;

&lt;p&gt;That's not a rejection. Cloudflare wasn't receiving any mail at all. The registrar had reset the domain's nameservers, so the Cloudflare zone was no longer the one the internet was asking. Two &lt;code&gt;dig&lt;/code&gt; commands show this in under a minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does a blank Email Routing Activity log mean?
&lt;/h2&gt;

&lt;p&gt;It means no sender reached Cloudflare's MX for that period. Every message Cloudflare does receive leaves a row with a status, even when it fails. The &lt;a href="https://developers.cloudflare.com/email-service/observability/logs/" rel="noopener noreferrer"&gt;Email Service logs&lt;/a&gt; page lists them: Forwarded, Handled, Dropped, Rejected, Delivery failed and Error. No row at all means no delivery attempt arrived.&lt;/p&gt;

&lt;p&gt;That splits the problem in two before you touch anything:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Activity log shows&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;th&gt;Where to look&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rows with Rejected or Delivery failed&lt;/td&gt;
&lt;td&gt;Cloudflare got it, the next hop failed&lt;/td&gt;
&lt;td&gt;Destination mailbox, SPF / DKIM / DMARC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rows with Forwarded, but mail is missing&lt;/td&gt;
&lt;td&gt;Delivered&lt;/td&gt;
&lt;td&gt;Spam folder and filters at the destination&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nothing after a certain day&lt;/td&gt;
&lt;td&gt;Cloudflare got nothing&lt;/td&gt;
&lt;td&gt;Nameservers, MX, domain status&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One caveat from my own zone. It routes 1 to 4 messages a day, and in September three days had zero rows in the &lt;code&gt;emailRoutingAdaptiveGroups&lt;/code&gt; GraphQL dataset, which the Activity log reads from. They were just quiet days. A gap only means something if the address normally gets mail every day, so send yourself a test message before reading anything into it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two dig commands that show a nameserver change
&lt;/h2&gt;

&lt;p&gt;Ask a public resolver, not the one on your laptop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short NS yourdomain.com @1.1.1.1
dig +short MX yourdomain.com @1.1.1.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A healthy Email Routing zone looks like this. It's my own zone, run on 2026-09-30:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;craig.ns.cloudflare.com.
priscilla.ns.cloudflare.com.

22 route3.mx.cloudflare.net.
84 route1.mx.cloudflare.net.
98 route2.mx.cloudflare.net.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;@1.1.1.1&lt;/code&gt; isn't decoration. When I first ran both commands for this note, my Mac's resolver returned nothing for NS and MX while it answered SOA fine. A public resolver gave the right answer straight away. An empty result from a local resolver proves nothing.&lt;/p&gt;

&lt;p&gt;If the NS line shows anything other than two &lt;code&gt;*.ns.cloudflare.com&lt;/code&gt; hosts, and MX comes back empty, the nameservers were changed at the registrar. Cloudflare's copy of your records is intact. Nobody is reading it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find the day it changed in WHOIS
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;whois yourdomain.com | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s2"&gt;"updated date|name server|status"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the Updated Date matches the day mail stopped, that's your change. In the case I looked at, it matched to the day. The new nameservers were &lt;code&gt;ns-cloud-a*.googledomains.com&lt;/code&gt; hosts, and their SOA serial was &lt;code&gt;1&lt;/code&gt;, which is what a zone that was created moments ago looks like.&lt;/p&gt;

&lt;p&gt;A Domain Status of &lt;code&gt;clientHold&lt;/code&gt; or &lt;code&gt;serverHold&lt;/code&gt; is a different problem with the same symptom. The registry has suspended the domain, so nothing resolves at all. That one gets fixed at the registrar, not in Cloudflare.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the dashboard still says Enabled and Locked
&lt;/h2&gt;

&lt;p&gt;The Email Routing overview reads Cloudflare's copy of the zone. As far as I can tell, Locked means the MX and SPF records in that copy match what Email Routing needs. It can't know that resolvers stopped asking that copy.&lt;/p&gt;

&lt;p&gt;Cloudflare does notice eventually, on its own schedule. Its &lt;a href="https://developers.cloudflare.com/dns/zone-setups/reference/domain-status/" rel="noopener noreferrer"&gt;domain status&lt;/a&gt; page says a zone that fails repeated nameserver checks moves to Moved. On the Free plan, a Moved zone is deleted 7 days later. Your DNS records go with it, and Email Routing rules live in that zone too.&lt;/p&gt;

&lt;p&gt;So there's a deadline. Check the zone status right away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.cloudflare.com/client/v4/zones?name=yourdomain.com"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$CF_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="s1"&gt;'.result[0].status'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"active"&lt;/code&gt; is fine. &lt;code&gt;"moved"&lt;/code&gt; means the week has started.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who changes nameservers when you didn't?
&lt;/h2&gt;

&lt;p&gt;Usually the registrar, or a site builder acting through it. The user in my case had a Squarespace notification from around the same day, and the domain's A record pointed at a Shopify IP. Squarespace's help page on &lt;a href="https://support.squarespace.com/hc/en-us/articles/4404183898125-Review-change-or-reset-your-domain-s-nameservers" rel="noopener noreferrer"&gt;domain nameservers&lt;/a&gt; says custom nameservers disconnect the domain from the site. Reconnecting a site from that dashboard, or the Use Squarespace nameservers button, puts Squarespace's defaults back.&lt;/p&gt;

&lt;p&gt;Other registrars have their own versions of this: a domain transfer that lands on default DNS, a "connect to website" wizard, an expired-then-renewed domain. The common thread is a change made outside Cloudflare, which Cloudflare's dashboard can't show you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, in order
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;At the registrar, set the nameservers back to the two shown on your Cloudflare zone's Overview page.&lt;/li&gt;
&lt;li&gt;Re-run &lt;code&gt;dig +short NS yourdomain.com @1.1.1.1&lt;/code&gt; until it shows Cloudflare. Squarespace quotes up to 48 hours, and it's usually much faster.&lt;/li&gt;
&lt;li&gt;Confirm the zone status is &lt;code&gt;active&lt;/code&gt;, then open Email Routing settings. If it flags MX or TXT records as missing, re-add them from there.&lt;/li&gt;
&lt;li&gt;If a site builder needs the domain, add its A or CNAME records in Cloudflare DNS instead of connecting from the builder's side. That path is what reset the nameservers in the first place.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What happens to mail sent during the outage?
&lt;/h2&gt;

&lt;p&gt;Most of it is still in retry queues. With no MX record, a sender falls back to the domain's A record and tries to deliver there. If that host doesn't take mail, the sender keeps retrying. &lt;a href="https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.4.1" rel="noopener noreferrer"&gt;RFC 5321&lt;/a&gt; says the give-up time generally needs to be at least 4 to 5 days.&lt;/p&gt;

&lt;p&gt;So mail from the last few days may arrive on its own once MX is back. Anything older than the sender's retry window already bounced to the sender. Nothing you do on the Cloudflare side brings that back, so it's worth telling the people who were likely to write.&lt;/p&gt;

&lt;p&gt;Nameservers and MX aren't in my &lt;a href="https://guushu.com/notes/cloudflare-zone-security-checklist/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-routing-stopped-working" rel="noopener noreferrer"&gt;Cloudflare zone security checklist&lt;/a&gt; yet, and after this case they should be. The two &lt;code&gt;dig&lt;/code&gt; lines above are the whole check. If you run a &lt;a href="https://guushu.com/notes/cloudflare-email-routing-support-address/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-routing-stopped-working" rel="noopener noreferrer"&gt;Cloudflare Email Routing support address&lt;/a&gt;, they're the first thing to try when it goes quiet.&lt;/p&gt;

&lt;p&gt;Nameservers and MX drifting while the dashboard stays green is the gap I'm sketching a daily check for. It's a one-question form today, not a product: &lt;a href="https://guard.guushu.com/zone-audit?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-routing-stopped-working" rel="noopener noreferrer"&gt;guard.guushu.com/zone-audit&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Original, with any later corrections: &lt;a href="https://guushu.com/notes/cloudflare-email-routing-stopped-working/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-routing-stopped-working" rel="noopener noreferrer"&gt;guushu.com/notes/cloudflare-email-routing-stopped-working/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>email</category>
      <category>dns</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Cloudflare free plan WAF rules I kept after 7 days of scanner logs</title>
      <dc:creator>HowardZlh</dc:creator>
      <pubDate>Thu, 08 Oct 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/howardzlh/cloudflare-free-plan-waf-rules-i-kept-after-7-days-of-scanner-logs-4pa6</link>
      <guid>https://dev.to/howardzlh/cloudflare-free-plan-waf-rules-i-kept-after-7-days-of-scanner-logs-4pa6</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://guushu.com/notes/cloudflare-free-plan-waf-rules/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-free-plan-waf-rules" rel="noopener noreferrer"&gt;guushu.com/notes&lt;/a&gt;. I keep the original updated, so this copy may lag.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;These are the Cloudflare free plan WAF rules running on my zone today: three custom rules and one rate limiting rule. I got there by reading a week of firewall events, and by undoing one change that blocked my own images.&lt;/p&gt;

&lt;p&gt;The short version: path rules do most of the work. The rate limit is where you hurt yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 7 days of Cloudflare security events looked like
&lt;/h2&gt;

&lt;p&gt;Between September 17 and 24, the zone saw about 34,000 requests. 55% of them hit a security action. The events sample I pulled had 5,208 rows from 920 IPs.&lt;/p&gt;

&lt;p&gt;None of it was aimed at me. It was the same background noise every public hostname gets: &lt;code&gt;.env&lt;/code&gt; files, &lt;code&gt;.git/config&lt;/code&gt;, WordPress logins, a React RCE probe for CVE-2025-55182, empty user agents, &lt;code&gt;Go-http-client&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The part that surprised me was cost. Blocked requests never reach the Worker. My busiest Worker peaked at 786 requests in a day, against a free allowance of 100,000.&lt;/p&gt;

&lt;p&gt;To see your own, the &lt;a href="https://developers.cloudflare.com/analytics/graphql-api/" rel="noopener noreferrer"&gt;GraphQL Analytics API&lt;/a&gt; has a firewall events dataset. On Free, it's sampled, and the hourly query budget is small, so don't loop it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;viewer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;zones&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;zoneTag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;zone id&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;firewallEventsAdaptive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;datetime_geq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-17T00:00:00Z"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;datetime_DESC&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;clientIP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;clientRequestPath&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;userAgent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;clientASNDescription&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Is it an attack or background scanning?
&lt;/h2&gt;

&lt;p&gt;Background scanning, in my case. Look at three things: the paths (secret files and CMS logins you don't have), the source network (cloud and VPS ranges, not home ISPs), and the timing (dozens of paths from one IP inside a second). If all three match, it's an automated scanner, and blocking it at the edge is enough.&lt;/p&gt;

&lt;p&gt;One warning before you trust a report. The first analysis I got, written by an AI assistant from the same API data, listed &lt;code&gt;/estimate&lt;/code&gt; as an unknown endpoint probe. That's my calculator page. It also counted &lt;code&gt;/zh-Hant/&lt;/code&gt; and &lt;code&gt;/sitemap.xml&lt;/code&gt;. Check every "attack path" against your own routes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two path rules: block WordPress and secret-file paths
&lt;/h2&gt;

&lt;p&gt;Free gives you &lt;a href="https://developers.cloudflare.com/waf/custom-rules/" rel="noopener noreferrer"&gt;5 custom rules&lt;/a&gt; and no regex, so every rule is a chain of &lt;code&gt;contains&lt;/code&gt;. I use two for paths.&lt;/p&gt;

&lt;p&gt;The WordPress one. I don't run WordPress anywhere, so nothing legitimate matches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vcl"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;http.request.uri.path contains &lt;span class="s2"&gt;"/wp-admin"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; or &lt;span class="p"&gt;(&lt;/span&gt;http.request.uri.path contains &lt;span class="s2"&gt;"/wp-includes"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
or &lt;span class="p"&gt;(&lt;/span&gt;http.request.uri.path contains &lt;span class="s2"&gt;"/wp-content"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; or &lt;span class="p"&gt;(&lt;/span&gt;http.request.uri.path contains &lt;span class="s2"&gt;"/wp-json"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
or &lt;span class="p"&gt;(&lt;/span&gt;http.request.uri.path contains &lt;span class="s2"&gt;"/xmlrpc.php"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; or &lt;span class="p"&gt;(&lt;/span&gt;http.request.uri.path contains &lt;span class="s2"&gt;"/wp-login"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
or &lt;span class="p"&gt;(&lt;/span&gt;http.request.uri.path contains &lt;span class="s2"&gt;"/wlwmanifest.xml"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; or &lt;span class="p"&gt;(&lt;/span&gt;http.request.uri.path contains &lt;span class="s2"&gt;"/wordpress/"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
or &lt;span class="p"&gt;(&lt;/span&gt;starts_with&lt;span class="p"&gt;(&lt;/span&gt;http.request.uri.path&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"/wp/"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The secret-file one is longer. The core of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(http.request.uri.path contains "/.env") or (http.request.uri.path contains "/.git/")
or (http.request.uri.path contains "/.aws/") or (http.request.uri.path contains "/.docker/")
or (http.request.uri.path contains "/credentials.yml") or (http.request.uri.path contains "/phpinfo")
or (http.request.uri.path contains "/info.php") or (http.request.uri.path contains "/dump.sql")
or (http.request.uri.path contains "/_profiler")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;contains "/.env"&lt;/code&gt; already catches &lt;code&gt;.env.production&lt;/code&gt; and &lt;code&gt;/api/.env&lt;/code&gt;. You don't need a line per variant.&lt;/p&gt;

&lt;p&gt;Why bother, if those files don't exist? A path rule blocks the first request. A rate limit only kicks in after the threshold, so a slow scanner walks under it forever.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F40s0yyuqf8qk7tv6qnlq.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F40s0yyuqf8qk7tv6qnlq.webp" alt="Security rules page on the zone: three active custom rules for WordPress paths, scanner IPs and curl user agents, and sensitive file paths, plus one active rate limiting rule, each with its event count" width="800" height="319"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's all four rules on my zone as of 2026-09-28, with the dashboard's event count next to each. IPs are replaced with documentation addresses.&lt;/p&gt;

&lt;h2&gt;
  
  
  The user agent rule: block tool clients, and pay for it
&lt;/h2&gt;

&lt;p&gt;The other custom rule blocks two known IPs plus any user agent containing &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;Go-http-client&lt;/code&gt;, &lt;code&gt;python&lt;/code&gt;, &lt;code&gt;scrapy&lt;/code&gt; or &lt;code&gt;fasthttp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It works. It also bit me. My own ops notes said Python and Go clients were allowed. The live rule blocked both, and a bare &lt;code&gt;curl https://guushu.com/&lt;/code&gt; returns 403 today. When I check my own site now, I send a browser user agent.&lt;/p&gt;

&lt;p&gt;Before copying this rule, list what calls your zone: webhooks, uptime checks, your own scripts. If any of them use a default library user agent, they're gone. This is the rule most likely to cause a quiet outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one free rate limiting rule, and how it 429'd my own images
&lt;/h2&gt;

&lt;p&gt;Free gets &lt;a href="https://developers.cloudflare.com/waf/rate-limiting-rules/" rel="noopener noreferrer"&gt;one rate limiting rule&lt;/a&gt;, counted per IP, with a fixed 10-second window and a fixed 10-second block.&lt;/p&gt;

&lt;p&gt;The AI report suggested dropping mine from 20 to 10 requests per 10 seconds. I did. Three days later, an image-heavy page on another subdomain was returning 429 on its own first screen. AI search crawlers were getting rate-limited too: GPTBot, ClaudeBot, PerplexityBot.&lt;/p&gt;

&lt;p&gt;The fix was to stop counting things that aren't worth counting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vcl"&gt;&lt;code&gt;&lt;span class="p"&gt;((&lt;/span&gt;http.host eq &lt;span class="s2"&gt;"guushu.com"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; or ends_with&lt;span class="p"&gt;(&lt;/span&gt;http.host&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;".guushu.com"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
and not &lt;span class="p"&gt;(&lt;/span&gt;http.request.uri.path.extension in &lt;span class="s1"&gt;{"js" "css" "png" "jpg" "jpeg" "gif" "webp" "avif" "svg" "ico" "woff" "woff2" "ttf" "json" "map" "txt" "xml" "webmanifest"}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
and not cf.client.bot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Back at 20 requests. The test: 40 concurrent HTML requests from one IP gave 20 × 200 and 20 × 429. 40 concurrent static requests gave 40 × 200. &lt;a href="https://developers.cloudflare.com/ruleset-engine/rules-language/fields/reference/cf.client.bot/" rel="noopener noreferrer"&gt;&lt;code&gt;cf.client.bot&lt;/code&gt;&lt;/a&gt; is true only for Cloudflare's &lt;a href="https://developers.cloudflare.com/bots/concepts/bot/verified-bots/" rel="noopener noreferrer"&gt;verified bots&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does a 429 on Googlebot mean you're blocking Google?
&lt;/h2&gt;

&lt;p&gt;Not necessarily. My logs had 107 rate limit events with a Googlebot user agent. Every one came from Google Cloud customer ranges, not the crawler. Google publishes its &lt;a href="https://developers.google.com/search/docs/crawling-indexing/verifying-googlebot" rel="noopener noreferrer"&gt;crawler IP ranges&lt;/a&gt;; check the source IP before you loosen anything. Real Googlebot and Bingbot had zero blocks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zone settings I changed, and ones I left off
&lt;/h2&gt;

&lt;p&gt;On the settings side, I moved minimum TLS to 1.2, turned on HSTS, and left &lt;a href="https://developers.cloudflare.com/bots/get-started/bot-fight-mode/" rel="noopener noreferrer"&gt;Bot Fight Mode&lt;/a&gt;, &lt;a href="https://developers.cloudflare.com/bots/additional-configurations/ai-labyrinth/" rel="noopener noreferrer"&gt;AI Labyrinth&lt;/a&gt; and the Free &lt;a href="https://developers.cloudflare.com/waf/managed-rules/" rel="noopener noreferrer"&gt;managed ruleset&lt;/a&gt; on. The full list with a curl for each is in my &lt;a href="https://guushu.com/notes/cloudflare-zone-security-checklist/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-free-plan-waf-rules" rel="noopener noreferrer"&gt;Cloudflare zone security checklist&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One caution on HSTS. My header now says &lt;code&gt;includeSubDomains; preload&lt;/code&gt;. Preload is hard to take back, so be sure every subdomain serves HTTPS before you add it.&lt;/p&gt;

&lt;p&gt;I left three things off. Under Attack Mode puts a challenge in front of every visitor, which is a lot of friction for background noise. mTLS wants a client certificate that none of my visitors have. Hotlink protection solves a problem these scans don't have.&lt;/p&gt;

&lt;p&gt;A week later, the numbers I'll look at are simple: whether rate limit events from home ISPs stay near zero (before the fix, about 3 of 69 blocked IPs were residential), and whether my own pages still load in one go.&lt;/p&gt;

&lt;p&gt;If you run more than one zone and want this kind of drift flagged daily, there's a one-question form, not a product yet: &lt;a href="https://guard.guushu.com/zone-audit?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-free-plan-waf-rules" rel="noopener noreferrer"&gt;guard.guushu.com/zone-audit&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Original, with any later corrections: &lt;a href="https://guushu.com/notes/cloudflare-free-plan-waf-rules/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-free-plan-waf-rules" rel="noopener noreferrer"&gt;guushu.com/notes/cloudflare-free-plan-waf-rules/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>security</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Cloudflare Email Routing vs Google Workspace vs Zoho for support@</title>
      <dc:creator>HowardZlh</dc:creator>
      <pubDate>Wed, 30 Sep 2026 02:08:42 +0000</pubDate>
      <link>https://dev.to/howardzlh/cloudflare-email-routing-vs-google-workspace-vs-zoho-for-support-7le</link>
      <guid>https://dev.to/howardzlh/cloudflare-email-routing-vs-google-workspace-vs-zoho-for-support-7le</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://guushu.com/notes/cloudflare-email-routing-vs-google-workspace-zoho/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-routing-vs-google-workspace-zoho" rel="noopener noreferrer"&gt;guushu.com/notes&lt;/a&gt;. I keep the original updated, so this copy may lag.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My own &lt;code&gt;support@&lt;/code&gt; runs on the first column of the table below, and for one person reading mail that's plenty. The comparison of Cloudflare Email Routing vs Google Workspace vs Zoho only gets interesting once you ask three things. Do you just need to receive at your domain? Do you need to reply as that address from a real mailbox? Do two people need to handle the same address?&lt;/p&gt;

&lt;p&gt;Each question knocks out a column. I left prices out of the table on purpose. They change, and the pricing pages are one click away. What each option can do doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which option fits which need?
&lt;/h2&gt;

&lt;p&gt;Receive only: Email Routing, $0, 10 minutes.&lt;/p&gt;

&lt;p&gt;Reply as &lt;code&gt;support@&lt;/code&gt; from a mailbox: Zoho's free tier or one Workspace seat is the shortest path.&lt;/p&gt;

&lt;p&gt;Two or more people on one address: run one of the self-hosted inboxes built on Email Routing, or pay someone to run that shape for you. The rest of this note is detail on those three lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comparison table
&lt;/h2&gt;

&lt;p&gt;Checked 2026-09-16 against &lt;a href="https://developers.cloudflare.com/email-service/" rel="noopener noreferrer"&gt;Email Routing&lt;/a&gt;, &lt;a href="https://workspace.google.com/pricing" rel="noopener noreferrer"&gt;Google Workspace pricing&lt;/a&gt; and &lt;a href="https://www.zoho.com/mail/zohomail-pricing.html" rel="noopener noreferrer"&gt;Zoho Mail pricing&lt;/a&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Cloudflare Email Routing&lt;/th&gt;
&lt;th&gt;Google Workspace (1 seat)&lt;/th&gt;
&lt;th&gt;Zoho Mail&lt;/th&gt;
&lt;th&gt;Self-hosted on Cloudflare&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Software cost&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;Per seat, monthly&lt;/td&gt;
&lt;td&gt;Free tier; paid per user&lt;/td&gt;
&lt;td&gt;$0 code; all three say they run on Workers Free for small teams, R2 activated separately&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Receives at your domain&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes, via Email Routing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stores mail&lt;/td&gt;
&lt;td&gt;No, forwards only&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes, your D1 / R2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reply as support@ later&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes, via Email Sending or Resend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiple addresses&lt;/td&gt;
&lt;td&gt;Up to 200 rules per domain, plus catch-all&lt;/td&gt;
&lt;td&gt;Aliases on one seat&lt;/td&gt;
&lt;td&gt;Aliases; limits per plan&lt;/td&gt;
&lt;td&gt;Unlimited patterns, one Worker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two people, one address&lt;/td&gt;
&lt;td&gt;One rule, one destination; fan-out needs a Worker&lt;/td&gt;
&lt;td&gt;Delegation / Groups&lt;/td&gt;
&lt;td&gt;Shared mailbox on paid tiers&lt;/td&gt;
&lt;td&gt;Yes, no seat pricing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max message size&lt;/td&gt;
&lt;td&gt;25 MiB&lt;/td&gt;
&lt;td&gt;Per their docs&lt;/td&gt;
&lt;td&gt;Per their docs&lt;/td&gt;
&lt;td&gt;25 MiB (Email Routing limit)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Programmable&lt;/td&gt;
&lt;td&gt;Yes, Email Workers&lt;/td&gt;
&lt;td&gt;Gmail API&lt;/td&gt;
&lt;td&gt;Zoho API&lt;/td&gt;
&lt;td&gt;It is your code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup&lt;/td&gt;
&lt;td&gt;10 min&lt;/td&gt;
&lt;td&gt;DNS verify + DKIM&lt;/td&gt;
&lt;td&gt;DNS verify + DKIM&lt;/td&gt;
&lt;td&gt;1 to 3 h, then you run it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DNS you hand over&lt;/td&gt;
&lt;td&gt;MX + SPF&lt;/td&gt;
&lt;td&gt;MX + SPF + DKIM + TXT&lt;/td&gt;
&lt;td&gt;Same&lt;/td&gt;
&lt;td&gt;MX + SPF, plus DKIM if you reply&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fourth column is three projects, checked 2026-09-16. &lt;a href="https://github.com/cloudflare/agentic-inbox" rel="noopener noreferrer"&gt;cloudflare/agentic-inbox&lt;/a&gt; is Cloudflare's own (Apache-2.0, Workers + D1). &lt;a href="https://github.com/HQBase/hqbase" rel="noopener noreferrer"&gt;HQBase&lt;/a&gt; is AGPL-3.0 (Workers + D1 + R2 + Queues). &lt;a href="https://github.com/mirza-rizvi/ResolveHQ" rel="noopener noreferrer"&gt;ResolveHQ&lt;/a&gt; is source-available, not open source (Email Routing + Resend + D1 + R2 + Queues).&lt;/p&gt;

&lt;p&gt;Scope and license differ, the stack doesn't. Details and the 30-line handler are in &lt;a href="https://guushu.com/notes/cloudflare-email-worker-inbox-d1/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-routing-vs-google-workspace-zoho" rel="noopener noreferrer"&gt;Cloudflare Email Worker inbox&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the table does not say
&lt;/h2&gt;

&lt;p&gt;Email Routing is a forwarder that can run code. A rule can target a Worker, and the Worker gets the raw MIME. Nothing else in the table lets you put a function between the MX record and the mailbox for free.&lt;/p&gt;

&lt;p&gt;Workspace is the one that just works: one seat, one mailbox. You pay for that seat every month, even for an address that might get ten messages a week. Its MX records also replace Cloudflare's.&lt;/p&gt;

&lt;p&gt;Zoho's free tier is real. It's fine for one person. The shared-mailbox features sit on paid plans, which puts Zoho in the same spot as Workspace once there are two of you.&lt;/p&gt;

&lt;p&gt;The self-hosted column works, and you're the one running it. Two people on one &lt;code&gt;support@&lt;/code&gt; with no seat pricing already exists three times over on GitHub, one of them maintained by Cloudflare. The catch: you keep the D1 schema migrated, and you get paged when the reply path breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who covers two people on one support@ without running it yourself?
&lt;/h2&gt;

&lt;p&gt;Workspace and Zoho do it with a second seat. The self-hosted projects do it for the cost of running them. Nobody in the table offers the same Worker-plus-D1 shape as a hosted service, for people who'd rather not run one more thing.&lt;/p&gt;

&lt;p&gt;If the first column is all you need, the setup and its three limits are in &lt;a href="https://guushu.com/notes/cloudflare-email-routing-support-address/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-routing-vs-google-workspace-zoho" rel="noopener noreferrer"&gt;Cloudflare Email Routing support address&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The hosted half of the fourth column, the same Worker-plus-D1 shape with someone else on the pager, is a waitlist page today, not a product. The form asks which address you route: &lt;a href="https://guard.guushu.com/inbox?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-routing-vs-google-workspace-zoho" rel="noopener noreferrer"&gt;guard.guushu.com/inbox&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Original, with any later corrections: &lt;a href="https://guushu.com/notes/cloudflare-email-routing-vs-google-workspace-zoho/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-routing-vs-google-workspace-zoho" rel="noopener noreferrer"&gt;guushu.com/notes/cloudflare-email-routing-vs-google-workspace-zoho/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>email</category>
      <category>productivity</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Cloudflare Email Routing support address: setup and 3 limits</title>
      <dc:creator>HowardZlh</dc:creator>
      <pubDate>Tue, 29 Sep 2026 02:01:05 +0000</pubDate>
      <link>https://dev.to/howardzlh/cloudflare-email-routing-support-address-setup-and-3-limits-35ph</link>
      <guid>https://dev.to/howardzlh/cloudflare-email-routing-support-address-setup-and-3-limits-35ph</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://guushu.com/notes/cloudflare-email-routing-support-address/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-routing-support-address" rel="noopener noreferrer"&gt;guushu.com/notes&lt;/a&gt;. I keep the original updated, so this copy may lag.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A Cloudflare Email Routing support address is the cheapest way to get &lt;code&gt;support@yourdomain.com&lt;/code&gt; into a mailbox you already read. It costs $0, needs no mail server, and takes about 10 minutes. I set one up for my own domain earlier this month, and that address now gets both my metrics alerts and my support mail.&lt;/p&gt;

&lt;p&gt;That second part is where this note ends up. Setup first, then the three limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Cloudflare Email Routing does and does not do
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://developers.cloudflare.com/email-service/" rel="noopener noreferrer"&gt;Email Routing&lt;/a&gt; takes over your domain's MX records and forwards inbound mail to an address you've verified. It stores nothing.&lt;/p&gt;

&lt;p&gt;Sending is a separate product, Email Sending, under the same Email Service umbrella. On its own, Routing is a free forwarder that lives in your DNS.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you set up a support address on Email Routing?
&lt;/h2&gt;

&lt;p&gt;Enable Email Routing on the zone, verify one destination mailbox, and add one rule for &lt;code&gt;support&lt;/code&gt;. Cloudflare writes three MX records and one SPF TXT record for you. The destination gets a verification link, and nothing forwards until someone clicks it. It's three dashboard screens and one &lt;code&gt;dig&lt;/code&gt; to confirm.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Enable it
&lt;/h3&gt;

&lt;p&gt;Dashboard, &lt;strong&gt;Compute&lt;/strong&gt;, &lt;strong&gt;Email Service&lt;/strong&gt;, &lt;strong&gt;Email Routing&lt;/strong&gt;, &lt;strong&gt;Onboard Domain&lt;/strong&gt;, then pick your zone. If the zone already has MX records pointing somewhere else, Cloudflare flags the conflict and offers to replace them. There's no "run both" mode.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwlf17tcv10s8mb0hypht.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwlf17tcv10s8mb0hypht.webp" alt="Email Routing list page with one onboarded domain showing Status Enabled and DNS records Locked, and the Onboard Domain button top right" width="800" height="216"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once it's on, the row reads Enabled and Locked. Locked means Cloudflare manages those MX records and won't let you edit them by hand.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short MX yourdomain.com
&lt;span class="c"&gt;# route1.mx.cloudflare.net.&lt;/span&gt;
&lt;span class="c"&gt;# route2.mx.cloudflare.net.&lt;/span&gt;
&lt;span class="c"&gt;# route3.mx.cloudflare.net.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Add a destination
&lt;/h3&gt;

&lt;p&gt;Click &lt;strong&gt;Destination addresses&lt;/strong&gt; on the same page and add the personal mailbox you already read. Cloudflare emails it a verification link. Until that's clicked, rules pointing at the address do nothing. Destinations belong to the account, so one verified mailbox works for every zone you onboard.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fumsdatpno08ho7u8tqka.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fumsdatpno08ho7u8tqka.webp" alt="Destination addresses page with one address marked Verified and an Add address field below it" width="800" height="280"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Create the rule
&lt;/h3&gt;

&lt;p&gt;Open your domain, then &lt;strong&gt;Routing rules&lt;/strong&gt;, &lt;strong&gt;Create routing rule&lt;/strong&gt;: email pattern &lt;code&gt;support&lt;/code&gt;, action &lt;strong&gt;Send to an email&lt;/strong&gt;, and pick the destination. You can also turn on &lt;strong&gt;Catch-all&lt;/strong&gt; so &lt;code&gt;anything@yourdomain.com&lt;/code&gt; lands somewhere instead of bouncing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzridjnzj05j4cw7scfsp.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzridjnzj05j4cw7scfsp.webp" alt="Routing rules tab with a disabled Catch-all rule set to Drop and an active support@ rule forwarding to one verified address" width="800" height="212"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# from any other account&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ping"&lt;/span&gt; | mail &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"routing test"&lt;/span&gt; support@yourdomain.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten minutes. Most tutorials stop here, and so did I at first.&lt;/p&gt;

&lt;h2&gt;
  
  
  3 things Email Routing can't do
&lt;/h2&gt;

&lt;h3&gt;
  
  
  It can't store mail
&lt;/h3&gt;

&lt;p&gt;There's no mailbox. If the destination files a message as spam, Cloudflare doesn't have a copy. The dashboard keeps an Activity log of recent messages and a daily forwarded/dropped count. That's it, unless the rule targets a Worker (next section).&lt;/p&gt;

&lt;h3&gt;
  
  
  It can't be shared
&lt;/h3&gt;

&lt;p&gt;A rule maps one address to one destination. To copy two people, you already need a Worker calling &lt;code&gt;forward()&lt;/code&gt; twice. Then each person has a copy and no idea whether the other one replied.&lt;/p&gt;

&lt;p&gt;So the personal-inbox setup breaks the day a second person needs to see support mail.&lt;/p&gt;

&lt;h3&gt;
  
  
  It can't reply as support@ later
&lt;/h3&gt;

&lt;p&gt;Hit reply in your mailbox, and the answer goes out from your personal address. An &lt;a href="https://developers.cloudflare.com/email-service/api/route-emails/email-handler/" rel="noopener noreferrer"&gt;Email Worker&lt;/a&gt; can call &lt;code&gt;message.reply()&lt;/code&gt;, but only once, inside the same SMTP session, and only to the original sender. If a person answers an hour later, you need Email Sending or another provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where an Email Worker gets you
&lt;/h2&gt;

&lt;p&gt;A routing rule can target a &lt;strong&gt;Worker&lt;/strong&gt; instead of "Send to an email". The Worker gets the raw message as a stream and can store it, forward a copy, or reject it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INSERT INTO mail (from_addr, to_addr, raw) VALUES (?,?,?)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forward&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;you@personal.example&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// destination must be verified&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole trick behind an inbox: the Worker is the inbox. It handles messages up to 25 MiB per the &lt;a href="https://developers.cloudflare.com/email-service/platform/limits/" rel="noopener noreferrer"&gt;Email Routing limits&lt;/a&gt;, on the free plan, with no extra bindings.&lt;/p&gt;

&lt;p&gt;The 30-line version with a D1 schema, plus three self-hosted inboxes built on it, is in &lt;a href="https://guushu.com/notes/cloudflare-email-worker-inbox-d1/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-routing-support-address" rel="noopener noreferrer"&gt;Cloudflare Email Worker inbox: store every message in D1&lt;/a&gt;. If you're weighing this against a paid mailbox, see &lt;a href="https://guushu.com/notes/cloudflare-email-routing-vs-google-workspace-zoho/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-routing-support-address" rel="noopener noreferrer"&gt;Cloudflare Email Routing vs Google Workspace vs Zoho&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The shared inbox I'm sketching on top of this pattern is a waitlist page today, not a product. The form asks one question: which address you route to: &lt;a href="https://guard.guushu.com/inbox?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-routing-support-address" rel="noopener noreferrer"&gt;guard.guushu.com/inbox&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Original, with any later corrections: &lt;a href="https://guushu.com/notes/cloudflare-email-routing-support-address/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-routing-support-address" rel="noopener noreferrer"&gt;guushu.com/notes/cloudflare-email-routing-support-address/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>email</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Cloudflare Email Worker inbox: store every message in D1 in 30 lines</title>
      <dc:creator>HowardZlh</dc:creator>
      <pubDate>Mon, 28 Sep 2026 07:14:09 +0000</pubDate>
      <link>https://dev.to/howardzlh/cloudflare-email-worker-inbox-store-every-message-in-d1-in-30-lines-ii1</link>
      <guid>https://dev.to/howardzlh/cloudflare-email-worker-inbox-store-every-message-in-d1-in-30-lines-ii1</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://guushu.com/notes/cloudflare-email-worker-inbox-d1/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-worker-inbox-d1" rel="noopener noreferrer"&gt;guushu.com/notes&lt;/a&gt;. I keep the original updated, so this copy may lag.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Email Routing forwards a message and keeps nothing. If you want a copy you can search later, point the routing rule at a Worker, write the message to D1, and forward it on. That's a Cloudflare Email Worker inbox, about 30 lines of TypeScript.&lt;/p&gt;

&lt;p&gt;Three self-hosted projects already ship that shape, two open source and one source-available. Below are the handler, the schema, the wrangler side, and those three projects in case you'd rather clone than write.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhdk87iqwwg5s7z7oj3d9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhdk87iqwwg5s7z7oj3d9.png" alt="Diagram: a sender's SMTP session reaches Email Routing, the rule sends it to an Email Worker, which inserts a row into D1 and forwards a copy to your mailbox, or rejects with a 5xx" width="799" height="258"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One caveat up front: I haven't run this handler in production. My own &lt;code&gt;support@&lt;/code&gt; still forwards straight to a mailbox. The code follows the Email Workers docs and the three projects below, which do run it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 3 self-hosted inboxes built on Email Routing
&lt;/h2&gt;

&lt;p&gt;All three take the same route. An Email Worker receives the message, parses it, and writes it to D1 (and R2 for attachments). A small web UI reads it back. I checked licenses and stacks against each repo on 2026-09-16.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;th&gt;License&lt;/th&gt;
&lt;th&gt;Stack&lt;/th&gt;
&lt;th&gt;Up-front cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/cloudflare/agentic-inbox" rel="noopener noreferrer"&gt;cloudflare/agentic-inbox&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;td&gt;Workers + D1; built as an inbox that AI agents can read and act on&lt;/td&gt;
&lt;td&gt;Free plan is enough to receive; one &lt;code&gt;wrangler deploy&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/HQBase/hqbase" rel="noopener noreferrer"&gt;HQBase/hqbase&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;AGPL-3.0&lt;/td&gt;
&lt;td&gt;Workers + D1 + R2 + Queues; sends through the Cloudflare &lt;code&gt;send_email&lt;/code&gt; binding; AI-native team email workspace&lt;/td&gt;
&lt;td&gt;Same deploy; R2 and Queues bindings to create&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/mirza-rizvi/ResolveHQ" rel="noopener noreferrer"&gt;mirza-rizvi/ResolveHQ&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Source-available (not open source)&lt;/td&gt;
&lt;td&gt;Email Routing + Resend + D1 + R2 + Queues; shared inbox, threading, AI assist&lt;/td&gt;
&lt;td&gt;Its README says it runs on Workers Free for small teams; R2 is activated separately, plus Resend DNS records for outbound&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Self-hosting any of them means you own the D1 migrations, the R2 bucket, the reply path, and the pager when it breaks. If that's fine and one of them fits, clone it and skip the rest of this note.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does Cloudflare Email Routing have no built-in mailbox?
&lt;/h2&gt;

&lt;p&gt;Cloudflare's product is the edge, not storage. &lt;a href="https://developers.cloudflare.com/email-service/" rel="noopener noreferrer"&gt;Email Routing&lt;/a&gt; sits at the MX layer. It accepts the SMTP session, applies your rules, and hands the bytes to a destination. Storing mail would mean retention, search, quotas, abuse handling. The way out is that a destination can be a Worker, and all three projects above take it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The email() handler
&lt;/h2&gt;

&lt;p&gt;An &lt;a href="https://developers.cloudflare.com/email-service/api/route-emails/email-handler/" rel="noopener noreferrer"&gt;Email Worker&lt;/a&gt; exports an &lt;code&gt;email()&lt;/code&gt; method alongside, or instead of, &lt;code&gt;fetch()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;PostalMime&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;postal-mime&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ForwardableEmailMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// full MIME, up to 25 MiB&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;PostalMime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INSERT INTO messages (id, from_addr, to_addr, subject, text_body, received_at) VALUES (?,?,?,?,?,?)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subject&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forward&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;you@personal.example&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;     &lt;span class="c1"&gt;// must be a verified destination&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things worth knowing before the first deploy.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;message.raw&lt;/code&gt; is a &lt;code&gt;ReadableStream&lt;/code&gt;, not a string. Wrap it in a &lt;code&gt;Response&lt;/code&gt; to read it once. A second read comes back empty.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;forward()&lt;/code&gt; only accepts verified destinations, the same list as the dashboard. An unverified address throws, and the sender gets a bounce. One rule maps to one destination or one Worker, so fan-out to several people means calling &lt;code&gt;forward()&lt;/code&gt; once per address.&lt;/p&gt;

&lt;p&gt;There's also &lt;code&gt;setReject(reason)&lt;/code&gt;. If the Worker decides a message is spam, &lt;code&gt;message.setReject("...")&lt;/code&gt; ends the SMTP session with a 5xx. Nothing gets stored or forwarded.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wrangler config and the routing rule
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"inbox"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"main"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"src/index.ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compatibility_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"d1_databases"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"binding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DB"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"database_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"inbox"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"database_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in the dashboard: &lt;strong&gt;Compute&lt;/strong&gt;, &lt;strong&gt;Email Service&lt;/strong&gt;, &lt;strong&gt;Email Routing&lt;/strong&gt;, your domain, &lt;strong&gt;Routing rules&lt;/strong&gt;, &lt;strong&gt;Create routing rule&lt;/strong&gt;, your address, action &lt;strong&gt;Send to a Worker&lt;/strong&gt;, pick &lt;code&gt;inbox&lt;/code&gt;. There's no wrangler command for the rule itself. It's a dashboard click or the REST API.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5r64qjohnr8dd1skm1ne.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5r64qjohnr8dd1skm1ne.webp" alt="Create routing rule dialog with an email pattern field, a domain picker and an Action menu explaining Send to an email, Send to a Worker and Drop" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Renaming the Worker drops the binding. Rename first, then re-point the rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  The D1 schema
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;from_addr&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;to_addr&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;subject&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;text_body&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;received_at&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;messages_received&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;received_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://developers.cloudflare.com/d1/platform/pricing/" rel="noopener noreferrer"&gt;D1's free tier&lt;/a&gt; is 5 million rows read and 100,000 rows written a day. A support address getting 100 messages a day uses 0.1% of the write budget.&lt;/p&gt;

&lt;p&gt;The index on &lt;code&gt;received_at&lt;/code&gt; is what stops "latest 50" from scanning the whole table. Every D1 case in &lt;a href="https://guushu.com/notes/cloudflare-bill-incidents-2026/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-worker-inbox-d1" rel="noopener noreferrer"&gt;Why Cloudflare bills spike&lt;/a&gt; is a missing index.&lt;/p&gt;

&lt;p&gt;Push attachments to R2 instead of D1. For text-only support mail, D1 alone is where I'd start.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you have, and what you don't
&lt;/h2&gt;

&lt;p&gt;You end up with a Worker that works as a mailbox. Every message is saved, searchable with SQL, and still forwarded to your phone.&lt;/p&gt;

&lt;p&gt;You don't get a UI, threading, "who's handling this", or a way for a person to reply from &lt;code&gt;support@&lt;/code&gt; an hour later. That's what the three projects at the top add. The setup steps before any of this are in &lt;a href="https://guushu.com/notes/cloudflare-email-routing-support-address/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-worker-inbox-d1" rel="noopener noreferrer"&gt;Cloudflare Email Routing support address&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The hosted version of this handler plus a two-person interface is a waitlist page today, not a product. The form asks which address you route: &lt;a href="https://guard.guushu.com/inbox?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-worker-inbox-d1" rel="noopener noreferrer"&gt;guard.guushu.com/inbox&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Original, with any later corrections: &lt;a href="https://guushu.com/notes/cloudflare-email-worker-inbox-d1/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-email-worker-inbox-d1" rel="noopener noreferrer"&gt;guushu.com/notes/cloudflare-email-worker-inbox-d1/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>database</category>
      <category>email</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Cloudflare zone security checklist: 12 read-only checks, one curl each</title>
      <dc:creator>HowardZlh</dc:creator>
      <pubDate>Thu, 24 Sep 2026 03:26:50 +0000</pubDate>
      <link>https://dev.to/howardzlh/cloudflare-zone-security-checklist-12-read-only-checks-one-curl-each-4jl5</link>
      <guid>https://dev.to/howardzlh/cloudflare-zone-security-checklist-12-read-only-checks-one-curl-each-4jl5</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://guushu.com/notes/cloudflare-zone-security-checklist/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-zone-security-checklist" rel="noopener noreferrer"&gt;guushu.com/notes&lt;/a&gt;. I keep the original updated, so this copy may lag.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I wrote this Cloudflare zone security checklist after "I'm sure I turned that on" led me to a WAF custom rule blocking &lt;code&gt;curl/*&lt;/code&gt; on a zone I never set up that way. Twelve checks, each with a terminal command, so the answer is a number instead of a memory.&lt;/p&gt;

&lt;p&gt;By hand it takes about ten minutes per zone. Fine for one zone. The last section is about what happens with more.&lt;/p&gt;

&lt;p&gt;Set these once for the shell snippets. The token is read-only (Zone Read, Zone Settings Read, DNS Read, Firewall Read).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CF_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ZONE_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ACCOUNT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
cf&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$CF_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"https://api.cloudflare.com/client/v4&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  TLS: minimum version, Always Use HTTPS, HSTS
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;a href="https://developers.cloudflare.com/ssl/edge-certificates/additional-options/minimum-tls/" rel="noopener noreferrer"&gt;Minimum TLS&lt;/a&gt; 1.2.&lt;/strong&gt; Anything lower is a compliance finding waiting to happen.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cf /zones/&lt;span class="nv"&gt;$ZONE_ID&lt;/span&gt;/settings/min_tls_version | jq .result.value    &lt;span class="c"&gt;# "1.2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. &lt;a href="https://developers.cloudflare.com/ssl/edge-certificates/additional-options/always-use-https/" rel="noopener noreferrer"&gt;Always Use HTTPS&lt;/a&gt;.&lt;/strong&gt; Plain-HTTP requests should 301, not serve content.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; http://example.com | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; location
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. &lt;a href="https://developers.cloudflare.com/ssl/edge-certificates/additional-options/http-strict-transport-security/" rel="noopener noreferrer"&gt;HSTS&lt;/a&gt; with max-age of 6 months or more.&lt;/strong&gt; No header means it's off. The toggle is under SSL/TLS, Edge Certificates.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://example.com | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; strict-transport-security
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmodh6qz3t5hsa3h0hkpj.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmodh6qz3t5hsa3h0hkpj.webp" alt="SSL/TLS Edge Certificates page: HSTS on with a 12 month max-age, subdomains and preload on, and Minimum TLS Version set to TLS 1.2" width="799" height="399"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's how checks 1 and 3 look in the dashboard on my zone. The curl output is the one to trust, since it's what browsers get.&lt;/p&gt;

&lt;h2&gt;
  
  
  WAF, bots, rate limiting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;4. &lt;a href="https://developers.cloudflare.com/waf/managed-rules/" rel="noopener noreferrer"&gt;WAF managed rules&lt;/a&gt; deployed.&lt;/strong&gt; The Free plan gets the Cloudflare Free Managed Ruleset, and Pro and up get the full set. Either way it should be deployed, not just available. &lt;code&gt;0&lt;/code&gt; means nothing is.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cf /zones/&lt;span class="nv"&gt;$ZONE_ID&lt;/span&gt;/rulesets/phases/http_request_firewall_managed/entrypoint | jq &lt;span class="s1"&gt;'.result.rules | length'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;5. &lt;a href="https://developers.cloudflare.com/bots/get-started/bot-fight-mode/" rel="noopener noreferrer"&gt;Bot Fight Mode&lt;/a&gt; on.&lt;/strong&gt; Security, Bots, one toggle. Turn it off only if you know which integration it breaks, and write that down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. &lt;a href="https://developers.cloudflare.com/waf/rate-limiting-rules/" rel="noopener noreferrer"&gt;Rate limiting&lt;/a&gt; on auth and form endpoints.&lt;/strong&gt; The Free plan includes one rule. Put it on &lt;code&gt;/login&lt;/code&gt;, &lt;code&gt;/api/auth&lt;/code&gt; or the contact form.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cf /zones/&lt;span class="nv"&gt;$ZONE_ID&lt;/span&gt;/rulesets/phases/http_ratelimit/entrypoint | jq &lt;span class="s1"&gt;'.result.rules[].description'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  DNS: DNSSEC, mail records, orphans
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;7. &lt;a href="https://developers.cloudflare.com/dns/dnssec/" rel="noopener noreferrer"&gt;DNSSEC&lt;/a&gt; enabled.&lt;/strong&gt; Empty output means the DS record isn't at the registrar, even if Cloudflare shows "pending".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short DS example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;8. SPF, DKIM, DMARC, even if the zone sends no mail.&lt;/strong&gt; Especially then. A domain with no mail policy can be spoofed by anyone. Baseline for a non-sending zone is &lt;code&gt;v=spf1 -all&lt;/code&gt; and &lt;code&gt;v=DMARC1; p=reject&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short TXT example.com | &lt;span class="nb"&gt;grep &lt;/span&gt;spf
dig +short TXT _dmarc.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;9. No orphaned records.&lt;/strong&gt; Records pointing at IPs or hosts you no longer control are a subdomain-takeover risk. The check is boring. List everything and ask "what's this?"&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cf &lt;span class="s2"&gt;"/zones/&lt;/span&gt;&lt;span class="nv"&gt;$ZONE_ID&lt;/span&gt;&lt;span class="s2"&gt;/dns_records?per_page=500"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.result[] | "\(.type) \(.name) -&amp;gt; \(.content)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Access, API tokens, audit log
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;10. Admin paths behind &lt;a href="https://developers.cloudflare.com/cloudflare-one/access-controls/policies/" rel="noopener noreferrer"&gt;Access&lt;/a&gt;.&lt;/strong&gt; Think &lt;code&gt;/admin&lt;/code&gt;, &lt;code&gt;/wp-admin&lt;/code&gt;, internal dashboards. An Access application with an email allow-list is free for up to 50 users.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cf /accounts/&lt;span class="nv"&gt;$ACCOUNT_ID&lt;/span&gt;/access/apps | jq &lt;span class="s1"&gt;'.result[].domain'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;11. &lt;a href="https://developers.cloudflare.com/fundamentals/api/get-started/create-token/" rel="noopener noreferrer"&gt;Scoped API tokens&lt;/a&gt;, not the Global API Key.&lt;/strong&gt; Automation runs on minimum permissions and an expiry. Tokens with &lt;code&gt;"expires_on": null&lt;/code&gt; are the ones to look at.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cf /user/tokens | jq &lt;span class="s1"&gt;'.result[] | {name, status, expires_on}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;12. &lt;a href="https://developers.cloudflare.com/fundamentals/account/account-security/review-audit-logs/" rel="noopener noreferrer"&gt;Audit log&lt;/a&gt; reviewed.&lt;/strong&gt; Cloudflare keeps one per account and nobody reads it. Once a month, count the changes. If the number surprises you, that's the finding.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# macOS date syntax; on Linux use: date -u -d '30 days ago' +%Y-%m-%dT00:00:00Z&lt;/span&gt;
cf &lt;span class="s2"&gt;"/accounts/&lt;/span&gt;&lt;span class="nv"&gt;$ACCOUNT_ID&lt;/span&gt;&lt;span class="s2"&gt;/audit_logs?since=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nt"&gt;-v-30d&lt;/span&gt; +%Y-%m-%dT00:00:00Z&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="s1"&gt;'.result | length'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why check Cloudflare zone settings you already set?
&lt;/h2&gt;

&lt;p&gt;Because settings drift and nobody sees it. The &lt;code&gt;curl/*&lt;/code&gt; rule above was on my zone before I started this project. It returned 403 to every probe, while browsers got through fine, and so did Workers and webhooks. Every check here is a read-only API call, so a cron can snapshot each zone daily, diff it, and email you only when something changed.&lt;/p&gt;

&lt;p&gt;Twelve checks, ten minutes, every zone, every month. For one zone that's a coffee. For forty it's a day, and it's the kind of day that gets skipped. The bill-side version of the same drift problem is in &lt;a href="https://guushu.com/notes/cloudflare-bill-incidents-2026/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-zone-security-checklist" rel="noopener noreferrer"&gt;Why Cloudflare bills spike&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The daily version of this list, read-only tokens in, a drift diff out, is a form today, not a product. It asks one question, how many zones you manage: &lt;a href="https://guard.guushu.com/zone-audit?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-zone-security-checklist" rel="noopener noreferrer"&gt;guard.guushu.com/zone-audit&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Original, with any later corrections: &lt;a href="https://guushu.com/notes/cloudflare-zone-security-checklist/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-zone-security-checklist" rel="noopener noreferrer"&gt;guushu.com/notes/cloudflare-zone-security-checklist/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>security</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why Cloudflare bills spike: 8 D1 and Durable Objects cases from 2026</title>
      <dc:creator>HowardZlh</dc:creator>
      <pubDate>Wed, 23 Sep 2026 09:24:44 +0000</pubDate>
      <link>https://dev.to/howardzlh/why-cloudflare-bills-spike-8-d1-and-durable-objects-cases-from-2026-288a</link>
      <guid>https://dev.to/howardzlh/why-cloudflare-bills-spike-8-d1-and-durable-objects-cases-from-2026-288a</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://guushu.com/notes/cloudflare-bill-incidents-2026/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-bill-incidents-2026" rel="noopener noreferrer"&gt;guushu.com/notes&lt;/a&gt;. I keep the original updated, so this copy may lag.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Eight public Cloudflare bill spikes turned up in 2026 threads on Cloudflare Community and Hacker News. Seven are D1 rows read. One is a Durable Objects alarm loop.&lt;/p&gt;

&lt;p&gt;Every fix was one index or one guard on a re-schedule. The fix was never the hard part. Nobody saw the meter move for days.&lt;/p&gt;

&lt;p&gt;What goes in the table: thread title, date, product, amount. No usernames, no quotes, no links to the threads. Amounts are what the poster said, rounded. Where they didn't say, the table says so.&lt;/p&gt;

&lt;p&gt;Last checked 2026-09-15. I add rows when I find them and bump the updated date at the top.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 8 incidents, by product and amount
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Product&lt;/th&gt;
&lt;th&gt;Amount&lt;/th&gt;
&lt;th&gt;What moved&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;04-16&lt;/td&gt;
&lt;td&gt;HN&lt;/td&gt;
&lt;td&gt;Durable Objects&lt;/td&gt;
&lt;td&gt;about $34,895 over 8 days&lt;/td&gt;
&lt;td&gt;Alarm re-scheduled itself in a loop; zero users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;06-29&lt;/td&gt;
&lt;td&gt;CF Community&lt;/td&gt;
&lt;td&gt;D1&lt;/td&gt;
&lt;td&gt;about $800&lt;/td&gt;
&lt;td&gt;Sitemap of 380,000 pages crawled; every page a full-table read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;08-03&lt;/td&gt;
&lt;td&gt;CF Community&lt;/td&gt;
&lt;td&gt;D1&lt;/td&gt;
&lt;td&gt;about $176&lt;/td&gt;
&lt;td&gt;Rows read overage on a small site&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;08-10&lt;/td&gt;
&lt;td&gt;CF Community&lt;/td&gt;
&lt;td&gt;D1&lt;/td&gt;
&lt;td&gt;about $4,115&lt;/td&gt;
&lt;td&gt;$5 subscription; D1 rows read ran for a month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;08-25&lt;/td&gt;
&lt;td&gt;CF Community&lt;/td&gt;
&lt;td&gt;Workers / D1&lt;/td&gt;
&lt;td&gt;not stated&lt;/td&gt;
&lt;td&gt;"Accidentally incurred a large billable usage"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;09-01&lt;/td&gt;
&lt;td&gt;CF Community&lt;/td&gt;
&lt;td&gt;D1&lt;/td&gt;
&lt;td&gt;invoice about $1,481, credit asked about $976&lt;/td&gt;
&lt;td&gt;1.476 trillion rows read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;09-03&lt;/td&gt;
&lt;td&gt;CF Community&lt;/td&gt;
&lt;td&gt;D1&lt;/td&gt;
&lt;td&gt;not stated&lt;/td&gt;
&lt;td&gt;Overage after a data migration; backfill loop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;09-11&lt;/td&gt;
&lt;td&gt;CF Community&lt;/td&gt;
&lt;td&gt;D1&lt;/td&gt;
&lt;td&gt;quota hit, no invoice yet&lt;/td&gt;
&lt;td&gt;Rows read shown as 235k in one view and 11.5M in another&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Seven D1, one Durable Objects. The smallest stated amount is $176. The largest is $34,895.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnnwqg6p97s6obkktkvjj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnnwqg6p97s6obkktkvjj.png" alt="Bar chart of the five stated amounts on a log scale: Durable Objects $34,895, then D1 at $4,115, $1,481, $800 and $176" width="800" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://developers.cloudflare.com/d1/platform/pricing/" rel="noopener noreferrer"&gt;D1 pricing&lt;/a&gt; is $0.001 per million rows read. So the 09-01 invoice is just what 1.476 trillion of anything costs on a per-million meter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does D1 bill rows read instead of requests?
&lt;/h2&gt;

&lt;p&gt;D1 meters rows scanned, not queries. A &lt;code&gt;SELECT ... WHERE slug = ?&lt;/code&gt; on a table with no index on &lt;code&gt;slug&lt;/code&gt; scans the whole table on every request. Take 10,000 rows and 100 requests a minute. After one day that's 1.4 billion rows for one small site, and the query count never moved.&lt;/p&gt;

&lt;p&gt;Every D1 row in the table is that meter. The 09-03 migration is the same thing in a different shape: a backfill ran &lt;code&gt;SELECT&lt;/code&gt; inside a loop over a table that had just grown.&lt;/p&gt;

&lt;h2&gt;
  
  
  Durable Objects alarm loops that never sleep
&lt;/h2&gt;

&lt;p&gt;The Durable Objects case is an alarm handler that set the next alarm at the end of every run. Failed runs too.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://developers.cloudflare.com/durable-objects/platform/pricing/" rel="noopener noreferrer"&gt;Durable Objects requests&lt;/a&gt; are metered per million on Workers Paid. A loop with no sleep and nobody watching runs until the invoice shows up. Eight days, about $34,895, zero users.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 10,000x jump that nobody saw for days
&lt;/h2&gt;

&lt;p&gt;None of these are Cloudflare being expensive. In every thread the metric jumped 10,000x or more, and the first sign was the invoice or a quota email.&lt;/p&gt;

&lt;p&gt;Cloudflare's &lt;a href="https://developers.cloudflare.com/billing/manage/budget-alerts/" rel="noopener noreferrer"&gt;Budget alerts&lt;/a&gt; arrived in 2026-04 and have been on by default for pay-as-you-go accounts since 2026-07. They send one email at the threshold. The docs call them informational only. They don't pause or cap anything, so someone still has to log in and act.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to see D1 rows read and DO requests before the invoice
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Dashboard: Storage &amp;amp; databases → D1 SQLite Database → your database → Overview. The "Rows read" chart is per database. You're looking for a flat line that suddenly isn't. Below it, the Queries table ranks statements; sort by Rows read and check the Rows read/returned column.&lt;/li&gt;
&lt;li&gt;GraphQL Analytics: dataset &lt;code&gt;d1AnalyticsAdaptiveGroups&lt;/code&gt;, dimension &lt;code&gt;databaseId&lt;/code&gt;, sum of &lt;code&gt;readQueries&lt;/code&gt; and &lt;code&gt;rowsRead&lt;/code&gt; over a date range. Same numbers as the dashboard, but you can script it.&lt;/li&gt;
&lt;li&gt;Durable Objects: Workers &amp;amp; Pages → Durable Objects → Metrics. Requests and alarm invocations per namespace. One namespace climbing on an otherwise flat account is the loop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4gz8rs6l6xcltgrovand.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4gz8rs6l6xcltgrovand.webp" alt="Queries table from one of my own D1 databases over 30 days. The fourth statement ran 71 times, read 965.52k rows and returned about 2,770 rows read per row returned" width="800" height="382"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's my own metrics database, last 30 days. The fourth row is a scan: 71 calls, 965,520 rows read, about 2,770 rows read for every row it returns. It costs nothing at this size. It's the same shape as every D1 row in the table above: a lookup by &lt;code&gt;max(created_at)&lt;/code&gt; per source on a table with no index. Two indexes, &lt;code&gt;(source, created_at)&lt;/code&gt; and &lt;code&gt;(created_at)&lt;/code&gt;, fixed it on 2026-09-28.&lt;/p&gt;

&lt;p&gt;The total doesn't tell you much. The check worth automating is whether today is more than 10x the last seven days.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I now guard my own D1 and Durable Objects usage
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Every D1 table I query by a non-primary-key column gets an index on that column. New queries go through &lt;code&gt;EXPLAIN QUERY PLAN&lt;/code&gt; first, and anything that says &lt;code&gt;SCAN&lt;/code&gt; gets an index or a rewrite. My own D1 is about 15,000 rows, and the screenshot above still caught one scan, so it's a habit I'm still enforcing, not a war story. The smallest example is the &lt;code&gt;received_at&lt;/code&gt; index in my &lt;a href="https://guushu.com/notes/cloudflare-email-worker-inbox-d1/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-bill-incidents-2026" rel="noopener noreferrer"&gt;Cloudflare Email Worker inbox&lt;/a&gt;, 30 lines that store mail in D1.&lt;/li&gt;
&lt;li&gt;I don't run Durable Objects yet. When I do, the re-schedule goes in the success path only, with a retry counter on the object.&lt;/li&gt;
&lt;li&gt;Since 2026-09-15 the "10x the last seven days" check has run on my own account as a small read-only Worker, &lt;a href="https://github.com/HowardZlh/usage-guard-collector" rel="noopener noreferrer"&gt;usage-guard-collector&lt;/a&gt; (MIT). Every 6 hours it reads the GraphQL Analytics API into a D1 table and flags any meter that's 10x its 7-day median. The hosted version linked below is still a waitlist page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What this page can't tell you: eight rows isn't a dataset, the amounts are self-reported, and I only read English-language threads. If you know a public thread that belongs here, the address in the footer works.&lt;/p&gt;

&lt;p&gt;I'm building a small watcher for exactly this gap. If a bill has surprised you, the one-question form is at &lt;a href="https://guard.guushu.com/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-bill-incidents-2026" rel="noopener noreferrer"&gt;guard.guushu.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Original, with any later corrections: &lt;a href="https://guushu.com/notes/cloudflare-bill-incidents-2026/?utm_source=devto&amp;amp;utm_medium=canonical&amp;amp;utm_campaign=notes-cloudflare-bill-incidents-2026" rel="noopener noreferrer"&gt;guushu.com/notes/cloudflare-bill-incidents-2026/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>serverless</category>
      <category>database</category>
      <category>devops</category>
    </item>
    <item>
      <title>AI is good! Me, using AI, am good! others, no!!!😅</title>
      <dc:creator>HowardZlh</dc:creator>
      <pubDate>Wed, 23 Sep 2026 09:22:02 +0000</pubDate>
      <link>https://dev.to/howardzlh/ai-is-good-me-using-ai-good-others-no-2c2d</link>
      <guid>https://dev.to/howardzlh/ai-is-good-me-using-ai-good-others-no-2c2d</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/infoinlet1/i-interviewed-the-people-who-rejected-me-heres-what-their-hiring-process-is-actually-optimizing-i9f" class="crayons-story__hidden-navigation-link"&gt;I interviewed the people who rejected me. Here's what their hiring process is actually optimizing for.&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
      &lt;a href="https://dev.to/infoinlet1/i-interviewed-the-people-who-rejected-me-heres-what-their-hiring-process-is-actually-optimizing-i9f" class="crayons-article__context-note crayons-article__context-note__feed"&gt;&lt;p&gt;Reframes hiring tests as price negotiations&lt;/p&gt;

&lt;/a&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/infoinlet1" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3941862%2Fcec34100-b85a-4c9e-8061-55c585109946.jpeg" alt="infoinlet1 profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/infoinlet1" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Info Inlet
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Info Inlet
                
                
              
              &lt;div id="story-author-preview-content-4712097" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/infoinlet1" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3941862%2Fcec34100-b85a-4c9e-8061-55c585109946.jpeg" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Info Inlet&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/infoinlet1/i-interviewed-the-people-who-rejected-me-heres-what-their-hiring-process-is-actually-optimizing-i9f" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Sep 22&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/infoinlet1/i-interviewed-the-people-who-rejected-me-heres-what-their-hiring-process-is-actually-optimizing-i9f" id="article-link-4712097"&gt;
          I interviewed the people who rejected me. Here's what their hiring process is actually optimizing for.
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag crayons-tag--filled  " href="/t/discuss"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;discuss&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ai&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/career"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;career&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/hiring"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;hiring&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/infoinlet1/i-interviewed-the-people-who-rejected-me-heres-what-their-hiring-process-is-actually-optimizing-i9f" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/exploding-head-daceb38d627e6ae9b730f36a1e390fca556a4289d5a41abb2c35068ad3e2c4b5.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;16&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/infoinlet1/i-interviewed-the-people-who-rejected-me-heres-what-their-hiring-process-is-actually-optimizing-i9f#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              4&lt;span class="hidden s:inline"&gt;&amp;nbsp;comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            6 min read
          &lt;/small&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
  </channel>
</rss>
