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.
Web Platform
How an Astro 6 site, a Cloudflare Worker, and an AT Protocol PDS share one deployment without sharing responsibilities.
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.
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.
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.
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.
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.
| Data | Lives in |
|---|---|
| Log source | Markdown in this repository |
| Published Log metadata and content | site.standard.document records on the PDS |
| Discussion | Replies to a Bluesky announcement, when one exists |
| Account records | The PDS Durable Object |
| Blobs | The PDS R2 bucket |
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.
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.