Bassam Ismail
Building Press
Building Press·Part 5 of 6
Engineering

Building Press, Part 5: A static export that reads a database

7 min read

A static export from a database can still lie to you at the edge. I checked the box and saw cf-cache-status: DYNAMIC. The site is a pure static export, a folder of pre-rendered HTML, and Cloudflare was revalidating it on every single request. The thing I had assumed was fast by construction was quietly doing work on every hit. That gap, between "static" as an architecture and "static" as an observable property at the edge, is the whole story of this part.

My thesis: a static site that reads a database is the right shape for a personal publication, but "static" is a claim you have to verify at the edge, not a guarantee you inherit from the build step. That choice also rests on the data model underneath it, which I wrote about in Building Press, Part 2: The data model is the spine.

Static export from a database happens at build time

The public site is a Next static export deployed to Cloudflare Pages. Every page, the posts, the reading list, the resume, is rendered at build time by reading Postgres and then frozen into HTML. Nothing is dynamic at request time. A visitor hits a file, not a function, and certainly not the database.

That has two payoffs. The caching story gets simple, because there is no per-request computation to reason about. And the security story gets simpler, because the public surface never touches Postgres at request time. There is no query to inject into, no connection string within reach of an anonymous visitor, no N+1 to exploit. The data is upstream of the freeze.

I want to be precise about the boundary, though, because "the database is never exposed" would be an overclaim. The Studio, the authoring dashboard, absolutely talks to Postgres at request time, behind authentication. That is the surface that still needs defending: auth, session handling, and the access controls around it. What the static export buys is that the public never participates in that. It is a smaller thing to defend than "the whole site talks to a database," and smaller is the point.

Publishing is a rebuild, not a write

Because the public artifact is frozen HTML, publishing a post cannot just flip a database flag and expect the world to update. It has to regenerate the artifact. So publish triggers a rebuild: the Studio container builds the blog from Postgres, deploys the static out/ to Cloudflare Pages with wrangler, and purges the edge cache so the new version is the one served. End to end it is live in about a minute.

The purge is the step people forget. Without it, you deploy fresh HTML and the edge keeps serving the old cached copy until its TTL lapses, so the post looks like it did not publish. Purging on every deploy keeps the bust trivial: I never reason about partial invalidation, I blow the whole cache and let the next request re-warm it.

I considered server-rendering the public site instead, the way most Next apps run. It would have removed the rebuild-on-publish dance entirely. I rejected it because it reintroduces exactly the two things the static export removes: a database in the request path, and per-request latency that varies with load. For a site that changes a few times a week and is read far more than it is written, paying a render on every read to avoid a build on every write is the wrong trade.

"Static = fast" was a lie until I made the edge agree

Here is where the assumption broke. I shipped the static export, saw the deploy succeed, and assumed the edge was serving cached HTML. It was not.

Deep-dive: why "static = fast" was a lie until I added a Cache Rule

The HTML came back with cache-control: max-age=0, must-revalidate and, tellingly, cf-cache-status: DYNAMIC. The edge was treating every page as uncacheable and revalidating against the origin on each request.

My first fix was the _headers file, where Cloudflare Pages lets you set caching rules per path. That worked for assets: the content-hashed /_next/static/* files got marked immutable, and the edge started returning HIT for them. But the HTML stayed DYNAMIC no matter what I put in _headers, because Cloudflare Pages ignores s-maxage on HTML documents specifically. The header was being written and then disregarded for exactly the resource I cared about.

The thing that actually moved it was a zone-level Cache Rule, configured on the Cloudflare zone rather than in the repo: mark the HTML cache-eligible and give it an edge TTL. Once that rule was in place the HTML finally returned a true edge HIT.

The measurable result: warm HTML TTFB dropped from roughly 158ms to roughly 96ms. The roughly 60ms that disappeared was the Pages render that a cached HIT skips. These are casual measurements from a handful of requests, not a benchmark, so treat them as the right order of magnitude rather than exact figures.

What you cannot cache away is the speed of light. Of that remaining 96ms or so, about 90ms is just the round trip to the nearest edge location. That part is geography, not architecture, and no cache rule touches it. It was a useful reminder that "make it faster" has a floor set by physics, and that once you are within shouting distance of the RTT, further caching work has nothing left to win.

There is a sharp edge to this approach that bit me, and it is worth more than a footnote.

Warning

A Cache Rule must be scoped to the public host. My first rule was host-agnostic, so it also matched the authenticated Studio on the same zone. Cloudflare happily cached Studio pages, returning cf-cache-status: HIT even though those responses were marked private, no-store, which meant it served stale and, worse, cross-session content to logged-in requests. The fix was to scope the rule's expression to the apex host so it only ever applies to the public site. A caching rule that does not know which host it is for is a security bug waiting to happen.

The covers come home at build time

One more thing the build step does, almost as a side effect, is bring the reading-list book covers in-house. The naive version hotlinks the cover images straight from Goodreads, roughly 85 of them. That is slow, uncached on my side, and one URL change away from a wall of broken images.

So the build downloads each cover into the static output, where it serves first-party and edge-cached like everything else, with the original remote URL kept as a per-cover fallback for anything that fails to fetch. It is the same principle as the rest of the site: pull the dependency in at build time, when a failure is loud and fixable, rather than at request time, when it is silent and the reader pays for it.

The durable lesson

The instinct to verify is the thing I would keep. "Static" felt self-evidently fast, "the _headers file handles caching" felt self-evidently true, and both were wrong in ways that only a curl -I and a look at cf-cache-status would reveal. A static export from a database is a genuinely good shape for this kind of site, and I would choose it again, but the architecture only describes what should happen. Whether the edge serves a HIT, whether a cache rule is reaching into a surface it should not, whether an image loads first-party or limps in over a third-party CDN, all of that lives one layer below the design and only shows itself when you go and measure it.