How this site is built
A small static site: Astro for content, Caddy for serving, Let's Encrypt for TLS — and a build that writes plain files to disk.
I wanted a place to write about infrastructure that didn’t involve running a CMS, a database, or a container orchestrator just to publish a paragraph. What I ended up with is a static site: content lives as Markdown in a git repository, a build step turns it into HTML, and a small web server hands out the files.
This post documents the setup, mostly so future-me remembers why it looks the way it does.
The shape of it
There are three pieces. Content is written in Markdown/MDX. Astro renders that into static HTML at build time. Caddy serves the resulting directory and terminates TLS.
flowchart LR A["Markdown / MDX<br/>src/content"] --> B["Astro build"] B --> C["dist/ (static HTML, CSS, JS)"] C --> D["rsync"] D --> E["Caddy on sed.is"] E --> F["HTTPS / Let's Encrypt"]
Nothing runs at request time except Caddy serving files. There is no server-side rendering, no database, and no runtime that can drift from what’s in the repository.
Why Astro
The requirements were ordinary for a writing site: Markdown authoring, syntax-highlighted code, diagrams, a table of contents, tags, and an RSS feed. Astro does all of that with very little configuration and produces a static output directory, which fits the deployment I already had.
Content is defined as a typed collection, so a malformed post fails the build instead of shipping:
const blog = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/blog" }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
category: z.enum(CATEGORIES).optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
projects: z.array(z.string()).default([]),
}),
});
The draft flag is the useful part: a post with draft: true is visible in development but excluded
from builds, the feed and the sitemap. Writing something half-finished and leaving it in the
repository is fine.
Code and diagrams
Code blocks are highlighted at build time, so there’s no highlighting JavaScript shipped to readers, and dual light/dark themes are selected with the same toggle as the rest of the site. Long lines wrap rather than causing horizontal scroll, which matters on a phone.
Diagrams use Mermaid. I convert fenced ```mermaid blocks into elements Mermaid can render during
a build step, then a small lazy-loaded script draws them in the browser. It’s a little JavaScript, but
diagrams are worth it:
sequenceDiagram participant U as Reader participant C as Caddy U->>C: GET /blog/some-post/ C-->>U: 200 OK (index.html from disk)
Serving it
Caddy does the rest. TLS certificates are issued and renewed automatically, the apex domain is the
canonical one, and www redirects to it.
sed.is {
tls {
issuer acme https://acme-v02.api.letsencrypt.org/directory
}
root * /var/www/sed.is
encode zstd gzip
file_server
}
Because the output is just files, deploying is a single rsync:
npm run build
rsync -az --delete dist/ root@sed.is:/var/www/sed.is/
What I’d change
Not much, yet. The pieces that matter — writing, code, diagrams, feeds — are all plain files and a build script, which is roughly the level of machinery this problem deserves.
Some things I may add later, only if the need is real:
| Idea | Why it’s deferred |
|---|---|
| Full-text search | Not needed until there’s enough to search |
| Comments | Adds a dependency and moderation for unclear benefit |
| Images pipeline | Nothing to optimise until there are images |
If any of those become annoying, the fix is a small build step, not a new platform.