Every engineer who starts a blog gets told the same thing: put Markdown files in a git repo, pick a static site generator (SSG), and push to publish. It’s solid advice for most people, but it didn't fit how I wanted to work.
I had four strict requirements that forced me off the standard path:
- Edit and save from anywhere. Autosave a moment after I stop typing from any device, with zero git ceremony or commit noise.
- Shareable preview links that stay fresh. Send someone a draft link, fix a typo, and they see the update immediately on reload without triggering a CI build.
- Full rendering fidelity on any device. What I see in the editor must match production down to the CSS and code highlighting—even when writing on an iPad without a local terminal or dev server.
- Static output for visitors. Readers get prerendered HTML served directly from a CDN. No application server runs on the public read path.
Most off-the-shelf setups fail at least one of these:
- Git + SSG dev server: Works great on a laptop, but fails requirement 3 on mobile or tablet devices that can't run the build toolchain.
- Browser-based git CMSs (Keystatic, Decap, TinaCMS): These handle editing and rendering well, but fail requirement 2 because updating a draft requires a new commit and a rebuild before preview links refresh.
- Hosted CMSs (Ghost, WordPress): Solve 1 through 3, but fail requirement 4 because public traffic hits a live application server.
The Tradeoffs
Moving state into a database means giving up git's reflog and built-in branch merging. If I open two editor tabs on different devices, the last write wins.
Recovery also depends entirely on Cloudflare D1's point-in-time restore window. Because site builds read directly from production data, a bad write goes live on the next publish. If you replicate this architecture, set up an automated off-platform backup script first.
The final system runs on a lightweight Cloudflare footprint: one Worker, one D1 database, one R2 bucket, Cloudflare Access for auth, and Workers Builds for publishing.
This system is explicitly designed for one writer. That constraint drastically simplifies authentication, session management, and editorial locks. Multi-author setups would require explicit locking and sanitization changes.
The Architecture
1. The Static Read Plane
Every public page (article pages, tag archives, RSS feed, sitemap) is prerendered at build time and served directly from Cloudflare's asset layer. Visitor traffic never executes custom server code. Images and assets are served directly from an R2 bucket on a dedicated subdomain, keeping media traffic off the Worker entirely.
2. The Server-Rendered Write Plane
The admin UI, REST API, and draft preview routes are the only paths that invoke the Worker. I enforce this through a closed route allowlist in wrangler.jsonc:
// wrangler.jsonc (admin and preview paths redacted)
"assets": {
"not_found_handling": "404-page",
"run_worker_first": ["/studio", "/studio/*", "/preview/*"]
}
Setting run_worker_first to an explicit array flips Cloudflare's default behavior. Only listed routes hit the Worker; everything else is handled or rejected at the static asset layer.
This keeps scanner bot traffic (/wp-admin, /.env) from invoking the Worker or generating log noise. It also guarantees the public site can't accidentally grow a runtime server dependency. If the Worker goes down, the live blog continues serving without interruption.
The catch: This list isn't validated at build time. A route you forget to include works fine in local development but throws a 404 in production.
The stack runs Astro 7 with output: 'server', the @astrojs/cloudflare adapter, and wrangler 4.120. Because the adapter, Vite plugin, and Wrangler share the underlying workerd runtime, mixing minor versions can corrupt local dev state. I pin exact versions in package.json using package manager overrides to prevent transitive bumps.
Building against the production database
The key trick behind this setup is that the static site build runs inside the Workers runtime. Astro's Cloudflare adapter executes astro build directly within workerd. Prerendering pages is simply running code that queries D1 through a standard database binding at build time. There are no API tokens to pass around or JSON export files to generate.
The D1 database binding uses "remote": true during builds. When Workers Builds runs, it queries the live production database directly. The build output is a true snapshot of published state.
Catching Silent Build Failures
Building an SSG inside workerd uncovered a dangerous edge case:
If a prerendered route throws an error, Astro's preview adapter captures the 500 response body, writes it out as an HTML file, and exits with code 0.
A broken page turns into a clean-looking build that deploys a 500 error page to production. I caught this after six broken pages had already deployed.
The Fix: A post-build assertion script inspects every emitted HTML file in the output directory. It asserts that pages contain expected HTML structures, verifies that article routes actually generated, and breaks the build pipeline if error stubs or missing diagrams are detected.
Content Storage: Markdown canonical, HTML stored
The database schema keeps posts, tags, join tables, media references, and pre-rendered diagrams simple and flat:
slug TEXT NOT NULL UNIQUE, -- the post's path segment
slug_locked INTEGER NOT NULL DEFAULT 0, -- 1 = hand-set, title edits never touch it
content_md TEXT, -- Authored Markdown (source of truth)
content_html TEXT, -- Rendered HTML (consumed by templates)
content_headings TEXT, -- Table of contents structure
content_issues TEXT, -- Image warning metadata
render_source TEXT NOT NULL, -- Which pipeline version generated content_html
source_native TEXT, -- Legacy CMS raw document format
source_rendered TEXT -- Legacy CMS rendered HTML output
content_html is authoritative on the read path. Nothing re-renders Markdown on request to serve a live page.
Storing only Markdown forces every frontend consumer to agree on a renderer forever. Storing only HTML makes raw editing difficult.
Storing both—and tagging the output with render_source—allows renderer updates to be run as explicit, reviewable batch migrations. When updating rendering libraries, I can re-render HTML in a background script and diff the changes before committing them.
Derived metadata like content_headings and content_issues are written during that same render pass to guarantee the table of contents anchors always match the stored HTML.
Always convert from the source platform's native JSON/document structure rather than its rendered HTML. Native structures preserve callout types, code block languages, and image alt text that raw HTML drops.
Rendering in the Browser
The admin UI uses CodeMirror 6 with a live preview pane. Instead of approximating styles, the preview pane imports the exact same Astro rendering pipeline used by the production build, bundled to run in the browser client.
When I hit save, the editor sends both the raw Markdown and the locally rendered HTML to the Worker API. The server validates structural shape (ensuring content_html is a string and headings are valid) and saves both directly to D1.
Cloudflare Worker isolates have a tight 128 MB memory limit. Heavy syntax highlighters (like Shiki) load compiled language grammars into module memory, which persists across requests inside a warm isolate.
Under heavy editing, syntax highlighting multiple languages quickly exhausts isolate memory and crashes the Worker without leaving error logs.
Moving Markdown rendering into the browser tab completely eliminates server-side memory pressure and cuts network overhead down to simple JSON writes.
Security Boundaries
Accepting pre-rendered HTML from the client creates a potential stored XSS vector if untrusted content enters the editor. Because this is a single-author system behind Cloudflare Access, I accept raw HTML passthrough to preserve legacy callout markup. If I ever open the system to second authors or public comments, I'll place an explicit HTML sanitizer on the write API.
Pre-rendered Neutralized SVG Diagrams
Mermaid diagrams require a browser DOM to render, which isn't available on a static read path. To avoid loading heavy JavaScript libraries in visitors' browsers, diagrams are pre-rendered into SVGs, cached in D1, and served as raw inline markup.
Dual-Theme SVGs with CSS Variables
Standard Mermaid renders bake fixed hex colors directly into the SVG element style attributes. Instead of storing separate light and dark SVGs, my capture script walks both light and dark DOM trees in parallel, replaces differing color values with CSS variables (var(--d-3, #16161A)), and injects scoped theme rules directly into the SVG:
<svg data-diagram="hash-key">
</svg>
<style>
[data-diagram="hash-key"] { --d-3: #F5F5F4; }
:root[data-theme="dark"] [data-diagram="hash-key"] { --d-3: #16161A; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) [data-diagram="hash-key"] { --d-3: #16161A; }
}
</style>
Toggling dark mode updates the CSS custom properties instantly without re-rendering or swapping SVG elements.
Diagram SVGs are indexed in D1 by a hash of their fence source text. Be sure to normalize per-line whitespace before hashing; otherwise, an automated code formatter or editor rule that strips trailing spaces will alter the key, breaking the cache lookup and causing the site to render raw code blocks instead of diagrams.
Media Storage on R2
Images pasted into the editor upload directly to an R2 bucket. They are served via a custom subdomain bound straight to the R2 bucket origin, backed by long-lived edge cache headers.
Editor -> R2 Bucket -> Dedicated Media Subdomain (CDN Cached)
Bypassing the Worker for image downloads avoids request invocation costs and puts media on Cloudflare's default asset caching network. Image keys use content hashes and are immutable, making 1-year browser cache headers safe.
Authentication: Cloudflare Access + In-Worker JWT Verification
The admin UI and draft APIs sit behind Cloudflare Access (Zero Trust SSO). However, the Worker does not rely solely on network-level protection.
Every incoming write request inspects the Cf-Access-Jwt-Assertion header and verifies it directly inside the Worker against Cloudflare's public JWKS endpoint using jose. It validates:
- Token signature
- Issuer and Audience (
aud) claims - User email against an explicit allowlist
// Fail-closed environment parsing
const allowedEmails = ENV.ALLOWED_EMAILS.split(',')
.map(e => e.trim())
.filter(Boolean); // Drops empty strings to prevent accidental wildcards
if (allowedEmails.length === 0) {
return new Response("Unauthorized", { status: 403 });
}
CSRF & Framing Rules
- CSRF Protection: Mutating requests (
POST,PUT,DELETE) require a custom application header (X-CMS-Action). Cross-origin browser requests cannot attach custom headers without triggering a CORS preflight, which Access blocks for unauthenticated origins. - Frame Ancestors: Admin routes explicitly send
Content-Security-Policy: frame-ancestors 'none'to block clickjacking attacks.
Shareable Draft Links
Draft previews use randomized, unguessable UUID tokens (/preview/[token]). These routes run outside Cloudflare Access so drafts can be shared with non-account holders. Draft endpoints send X-Robots-Tag: noindex, no-store and return identical 404 responses for invalid or revoked tokens to prevent token enumeration.
The Publishing Sequence
When I click Publish:
- The Worker updates the post status to
publishedin D1. - The Worker triggers an HTTP
POSTto a secret Cloudflare Workers Builds deploy hook. - Workers Builds executes
astro buildinsideworkerd, pulling fresh published records from D1. - New static HTML pages deploy directly to Cloudflare's CDN.
Practical Gotchas
If you build a similar architecture, watch out for these subtle traps:
- Unvalidated SSR Allowlists: Routes omitted from
run_worker_firstinwrangler.jsoncwork smoothly in local development but return 404 in production. Always audit production routes after adding new endpoints. - Multi-Account CLI Builds: If your Cloudflare account has access to multiple organizations, running a local build against a remote binding (
"remote": true) fails unless youraccount_idis explicitly declared inwrangler.jsonc. - D1 Statement Limits: D1 enforces a maximum SQL statement size limit. Large technical posts containing embedded code blocks and inline SVGs can hit statement size ceilings if you attempt bulk SQL updates.
Operating Costs
At the time of writing, this blog contains:
- 60 total posts
- 27 tag archives
- 26 cached diagram SVGs
- 230 media files in R2 (~23 MB)
The entire infrastructure runs comfortably inside Cloudflare's Free Tier limits. Because public visitors hit purely static CDN assets, database read units and Worker invocation usage remain almost flat outside of active writing sessions.
Closing Thoughts
This architecture isn't meant to be a universal replacement for git-based blogging or (self-)hosted platforms. Its value comes from tight design choices tuned to a single author:
- A closed SSR allowlist so the public site stays static by default.
- Storing both Markdown and HTML to decouple editing from rendering transitions.
- Client-side Markdown processing to respect edge runtime memory constraints.
- JWT validation inside the Worker to enforce security at the code layer rather than trusting network rules alone.
By letting Cloudflare handle static delivery and isolating server code strictly to admin tasks, the blog stays fast for readers, easy to edit from anywhere, and virtually maintenance-free.
For those that maybe wondering, prior to this I was using Ghost deployed on Fly. Fly is amazing, and it is fairly trivial to host your Ghost there. But for me, I did not need Ghost, I did not need the newsletters, the marketing or anything else it offered. One lest piece to manage.
PS: I guess it is not really zero servers, just someone elses and none that I need to manage.



