Skip to content
Mac Long

Web Platform

Building this site with Astro

How an Astro 6 site, a Cloudflare Worker, and an AT Protocol PDS share one deployment without sharing responsibilities.

· 7 min read
AstroCloudflareDid:Web
A layered card showing one Cloudflare Worker routing between this Astro site and its AT Protocol PDS.

From hand-written pages to content collections

This site began as hand-written HTML with a few vanilla JavaScript features: a search box, a theme toggle, and a Log archive with client-side filtering. It worked, but adding a Log meant editing an index page and copying a template.

Astro’s content collections gave the site a better shape. Each Log is a Markdown file in src/content/logs, with frontmatter checked by a Zod schema. Adding a file now creates a typed entry that the archive, homepage, and individual Log route can all use.

typescript
const logs = defineCollection({
  loader: glob({
    pattern: "**/*.md",
    base: "./src/content/logs",
  }),
  schema: z.object({
    title: z.string(),
    date: z.date(),
    /* ... */
  }),
});

The public pages are prerendered, but the deployment itself uses Astro’s server output and the Cloudflare adapter. That leaves room for the same Worker to handle the site’s AT Protocol services without turning the site into a client-rendered application. Alpine.js supplies small browser interactions, such as search, filtering, likes, and the theme toggle. Astro still renders the pages.

One Worker, two hostnames

maclong.dev and id.maclong.dev point to the same Cloudflare Worker. The worker checks the Host header: requests for id.maclong.dev go to Cirrus, and everything else goes to Astro’s handler.

typescript
import cirrus, { AccountDurableObject } from "@getcirrus/pds";
import astroHandler from "virtual:astro-cloudflare/server";

export { AccountDurableObject };

export default { fetch(request, env, ctx) { const hostname = request.headers.get(“host”)?.split(”:”)[0];

<span class="tok-kw">if</span> (hostname === env.PDS_HOSTNAME) {
  <span class="tok-kw">return</span> cirrus.<span class="tok-fn">fetch</span>(request, env, ctx);
}

<span class="tok-kw">return</span> astroHandler.<span class="tok-fn">fetch</span>(request, env, ctx);

}, };

Cirrus provides the AT Protocol Personal Data Server at id.maclong.dev. Its account data lives in an AccountDurableObject, backed by SQLite, and its blobs live in the BLOBS R2 bucket. Exporting the Durable Object from the worker entrypoint is essential; a binding in Wrangler configuration is not enough if the class never reaches the built Worker.

This arrangement keeps the responsibilities clear. The portfolio is a public site. The PDS is the identity and record service behind it. They can share a deployment without pretending to be the same application.

Publishing Logs as AT Protocol records

The site also publishes Logs through the Astro Standard Site conventions. The mise run atproto:sync task turns each Markdown Log into a site.standard.document record on the PDS, then writes the resulting record keys into src/data/atproto-documents.ts.

When a Log has a published document record, its page emits the corresponding link tag. That gives AT Protocol clients a stable, structured way to discover the canonical content instead of relying only on a scraped web page.

Discussion is separate. A Log can have a bskyPostUri in its frontmatter, which adds a link to its Bluesky announcement. Replies remain on Bluesky rather than being copied into a second comment system here. The mise run atproto:announce task creates announcements for Logs that do not yet have one.

What lives where
DataLives in
Log sourceMarkdown in this repository
Published Log metadata and contentsite.standard.document records on the PDS
DiscussionReplies to a Bluesky announcement, when one exists
Account recordsThe PDS Durable Object
BlobsThe PDS R2 bucket

Identity is a deployment decision

This deployment currently uses did:web:id.maclong.dev. That is a sensible fit for a personal site tied to a domain, but it is worth understanding the trade-off before deploying.

A did:web identity depends on DNS. If control of the domain is lost, the identity has no recovery path. A did:plc identity is controlled by a signed operation log instead, which makes it more suitable when an identity must move between domains or PDS providers. If I were moving an existing account with followers, I would begin with that existing did:plc identity rather than plan a migration later.

Checking the PDS as part of normal work

The site has a mise run pds:verify task that checks the PDS health endpoint, AT Protocol DID endpoints, and com.atproto.server.describeServer. These are small checks, but they catch the difference between a Worker that deploys and a PDS that is actually usable.

The useful lesson from this setup is not that everything belongs in one Worker. It is that the boundaries need to be explicit: route by hostname, export the Durable Object the PDS needs, keep the content source in Git, and publish the records that make the content portable.

Discuss on Bluesky