Skip to content
andrew.dunn.dev

Part 2: Workers + R2

Part 2 of the digital garden series.

The file size wall

The previous site ran on Hugo with Cloudflare Pages. Pages has a file size limit on deployment assets, which meant videos lived on YouTube. That always bothered me. If the point is owning your content in one place, depending on external URLs and third-party players is the wrong direction.

Cloudflare Workers with R2 object storage solved this. Static assets (HTML, CSS, JS, fonts) are served via Workers Static Assets. Media files live in an R2 bucket and are served through the same Worker. One domain, no external dependencies for content.

How the Worker fits together

The key configuration choice is run_worker_first = true in Wrangler. Every request routes through the Worker before falling through to static assets. This means every response gets the same security headers (Content Security Policy, X-Frame-Options, Referrer-Policy, Permissions-Policy), regardless of whether it’s HTML, CSS, or a media file.

/media/*everything elseREQUESTany pathsecondbrain.dunn.devCLOUDFLARE WORKERheaders + draft gaterun_worker_first = trueOBJECT STORER2 bucketwebp, mp4, m4aSTATIC ASSETSQuartz buildhtml, css, js, fonts

The Worker runs first, so one pass of security headers and one draft gate cover a video out of R2 and a page out of the static build alike. The path prefix only picks the source.

The routing

The Worker handles three path patterns:

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url)

    if (url.pathname.startsWith("/media/")) {
      return withSecurityHeaders(await handleMedia(request, env, url))
    }

    if (url.pathname.startsWith("/api/")) {
      return withSecurityHeaders(await handleApi(request, env, url))
    }

    const assetResponse = await env.ASSETS.fetch(request)
    // ... apply security headers ...
  },
}

/media/* reads from the R2 bucket. /api/* is a stub for now. Everything else falls through to static assets. Media gets 24-hour cache headers with R2’s native etag validation.

Observability

Workers has better observability than Pages. Per-request invocation logs (path, status, execution time, errors) plus R2’s own metrics for object reads and bandwidth. When I was debugging CSP issues with Cloudflare’s Web Analytics beacon, this was the difference between guessing and knowing.

Two independent deploy paths

The CI pipeline separates site deployment from media deployment. deploy-site runs on every push (build Quartz, deploy via Wrangler). deploy-media only runs when files in media/ change, pulling them via Git LFS and uploading to R2. A typo fix doesn’t trigger a media reupload. The pipeline is covered in more detail in part 3.

Draft preview

An early benefit: draft gating without a separate staging environment. Posts with draft: true are built into the site alongside everything else (the Quartz RemoveDrafts filter is disabled). At build time, a manifest of draft paths is generated and written to _drafts.json. The Worker checks every incoming request against it.

If the path is a draft and the request doesn’t carry a preview cookie, the Worker returns a 404. To preview, I visit /preview?token=SECRET (token stored as a Wrangler secret), which sets an HttpOnly, Secure, SameSite cookie. With the cookie, drafts render normally.

const drafts = await loadDrafts(env)
if (isDraftPath(url.pathname, drafts) && !hasPreviewCookie(request, env)) {
  return withSecurityHeaders(new Response("Not Found", { status: 404 }))
}

Public visitors see a 404, I see the draft. On Pages this would have required a separate preview deployment. The Worker makes it trivial because it already intercepts every request.

I’m hoping this means I won’t have to migrate when I want something dynamic later. Pages would have required it. Workers started dynamic and happens to serve static files well.

Next: Part 3: The Makefile