Skip to content
8 min read

sybilsedge.com

Personal site built with Astro, deployed on Cloudflare Workers. Blueprint aesthetic with dark mode, content collections, and live GitHub activity feed.

AstroCloudflareTypeScript
// Series · Part 1 The Blueprint Files

Notes on Building a Personal Site

A conversational postmortem on shipping a personal site with Astro, Cloudflare Workers, and more sharp edges than expected.


There’s a particular kind of hubris that comes with building your own personal site as a developer. You’ve shipped production systems. You understand CI/CD pipelines. How hard can a portfolio site be?

The answer, as always, is: harder than it looks — and more interesting than you expected.

This is a collection of notes from building sybilsedge.com — a personal site built with Astro v6, deployed on Cloudflare Workers. Not a tutorial. Not a best-practices guide. Just honest notes on what I ran into and what I learned.


The Stack

The core technology choices were:

  • Astro v6 — static-first framework with server rendering support
  • @astrojs/cloudflare v13 — adapter for deploying to Cloudflare Workers
  • Tailwind CSS v4 — utility-first styling
  • Cloudflare Workers — edge serverless runtime

On paper this is a clean, modern stack. In practice, Astro v6 and the v13 Cloudflare adapter were still shaking out breaking changes, and several things that worked fine in tutorials written six months ago had quietly stopped working.


The API That Disappeared

The first real wall was accessing environment secrets. The pattern everyone uses — Astro.locals.runtime.env.MY_SECRET — throws a hard error in Astro v6:

Astro.locals.runtime.env has been removed in Astro v6.
Use import { env } from 'cloudflare:workers' instead.

That’s it. The entire runtime.env accessor is gone. The fix is clean once you know it:

import { env } from "cloudflare:workers";
const githubToken = env.GITHUB_TOKEN;

But the error only surfaces at runtime — the TypeScript compiler doesn’t catch it because cloudflare:workers needs a module declaration. Adding this to worker-configuration.d.ts resolves it:

declare module "cloudflare:workers" {
  const env: Env;
  export { env };
}

The lesson here isn’t really about this specific API. It’s that when you’re on the bleeding edge of a framework version, the official docs lag behind. The most reliable source of truth is the adapter’s changelog and the error message itself.


Sessions, KV, and a Phantom Warning

Throughout the build, every Cloudflare deployment log contained this line:

[@astrojs/cloudflare] Enabling sessions with Cloudflare KV with the "SESSION" KV binding.

The site doesn’t use sessions. Adding sessions: false to the adapter config didn’t stop the warning. The warning kept appearing even after the config change.

Eventually I traced it to patch-wrangler.mjs — a postbuild script that strips session config from the generated wrangler.json before deployment. The Astro build step itself was injecting the session config regardless of the adapter option; the patch script was the safety net.

The practical takeaway: some warnings in build output are informational, not actionable. This one didn’t affect the deployed site at all. Learning to distinguish noise from signal in build logs is a real skill.


OG Images on a Workers Runtime

Open Graph images — the preview cards that appear when you share a link on social media — seem simple until you try to generate them dynamically on Cloudflare Workers.

The standard approach is Satori (React-to-SVG) + Resvg (SVG-to-PNG), served from an API endpoint. The problem: Cloudflare Workers don’t support the Node.js APIs that Resvg requires. No native modules, no filesystem, no WASM unless explicitly bundled.

The solution was to move OG image generation entirely to build time. A Node.js script (scripts/generate-og.mjs) runs after astro build, reads page definitions, renders PNGs using Satori and Resvg, and writes them to dist/client/og/. They’re served as static assets — zero runtime cost, no Worker overhead.

This works well for a personal site where pages don’t change frequently. The tradeoff is that dynamic content (new blog posts, new recipes) requires a new build to get a new OG image. That’s an acceptable constraint.

Satori Has Opinions

Satori is excellent but opinionated. Two things that will break your build without obvious error messages:

Every element with more than one child must have display: flex explicitly set. Satori doesn’t support normal block layout. If you forget one display: 'flex' deep in a nested component, you get a cryptic error about layout calculation failing.

textTransform: 'uppercase' is silently ignored. Satori doesn’t support CSS text transforms. If you want uppercase text, call .toUpperCase() in JavaScript before passing the string.

Font loading must succeed before calling satori(). The try/catch pattern for loading fonts from node_modules is convenient, but if both fonts fail silently and you call satori() with an empty fonts array, you get:

Error: No fonts are loaded. At least one font is required to calculate the layout.

Add a hard exit if fonts don’t load:

if (fonts.length === 0) {
  console.error("ERROR — no fonts loaded.");
  process.exit(1);
}

Fail loudly. Silent failures in build scripts are the worst kind.


The Lock File Is Load-Bearing

Every time a new dependency is added to package.json without running npm install locally and committing the updated package-lock.json, the Cloudflare CI build fails immediately:

npm error `npm ci` can only install packages when your package.json and
package-lock.json are in sync.

This isn’t a Cloudflare-specific issue — it’s how npm ci works by design. But it’s easy to forget when you’re pushing package.json changes directly from a GitHub tool or web editor without going through a local npm install first.

The workflow that avoids this:

  1. Add the dependency to package.json
  2. Run npm install locally to update the lock file
  3. Commit both files together
  4. Push

If you’re editing package.json directly in GitHub’s web editor or via API, you’ll always need that extra local step before the build will pass.


Images: Keep It Simple First

The temptation when building on a modern stack is to reach for Cloudflare Images or a dedicated image CDN immediately. Resist it, at least initially.

For a personal site, the simplest approach is:

  • Commit images directly to the repo under src/assets/images/
  • Use Astro’s <Image /> component with the image() schema validator in content collections
  • Let Astro’s compile-time optimization convert them to WebP and add proper width/height attributes

The schema validator is worth emphasizing — it type-checks image paths at build time. A broken image reference fails the build rather than shipping a broken <img> tag to production. That’s a meaningful quality improvement over the classic approach of just dropping URLs into frontmatter.

The content collection schema pattern for images:

schema: ({ image }) =>
  z.object({
    title: z.string(),
    image: z
      .object({
        src: image(),
        alt: z.string(),
      })
      .optional(),
  });

For galleries — recipe steps, project progress photos — an images array alongside the singular image hero works cleanly:

image:
  src: "../../assets/images/projects/deck-hero.jpg"
  alt: "Completed deck"
images:
  - src: "../../assets/images/projects/deck-01-framing.jpg"
    alt: "Framing and ledger board"
  - src: "../../assets/images/projects/deck-02-complete.jpg"
    alt: "Finished deck with railing"

image is the hero used on cards and OG images. images is the detail-page gallery. The separation is clean and avoids over-engineering a dedicated gallery system before you need one.


What Preview URLs Will and Won’t Show You

On a server-rendered Astro site deployed to Cloudflare Workers, preview deployment URLs reflect the last successful build — not your local working state. This means:

  • Images committed to src/assets/ won’t appear until a build runs and processes them
  • Environment secrets aren’t bound to preview deployments unless explicitly configured
  • Runtime errors that only occur under the Cloudflare runtime (not local astro dev) only surface after a deploy

The practical workflow is: commit → push → wait for the preview build to complete → verify on the preview URL. Local astro dev is useful for layout work but not a reliable proxy for production behavior on the Workers runtime.


Things I’d Do Differently

Set up the TypeScript declarations for cloudflare:workers before writing any runtime code. That one setup step would have prevented several hours of debugging runtime errors that the compiler should have caught.

Write build scripts defensively from the start. Every file read, every font load, every fetch in a build script should fail loudly with a clear message and a non-zero exit code. Silent failures in build tooling are the hardest bugs to diagnose.

Don’t chase Lighthouse scores during active development. The score fluctuates based on what’s deployed. Optimize at the end of a feature, not during it. The site shipped with 98 Performance / 100 Accessibility / 100 Best Practices, but chasing those numbers mid-build just creates noise.


What Worked Well

The Astro content collections system is genuinely good. Typed frontmatter with build-time validation is a substantial improvement over the traditional markdown-with-loose-frontmatter approach. The ergonomics of getCollection() and the schema system make content-heavy sites feel properly engineered rather than held together with string.

Cloudflare Workers is a solid deployment target for a site like this. The edge network is fast, the free tier is generous for personal traffic, and the cloudflare:workers module API — once you know to use it — is clean and simple.

And Satori, despite its opinions, produces genuinely nice output for OG images. The blueprint aesthetic — dark background, cyan accents, Orbitron display font — renders well at 1200×630 and looks intentional rather than generated.


The site is live. The build is green. The Lighthouse scores are what they are. On to the next thing.

// Comments