How we built this blog
The stack behind dotko.id, the decisions that shaped it, and the steps to build the same thing yourself.
This site is new, so before writing about anything else, we wanted to write down how it works and why we built it this way. None of the choices here are particularly unusual, but we think the combination works well for what we wanted. There were alternatives for almost every part of the stack, and some of them looked better on paper. We still ended up preferring the simpler setup.
What we wanted
We started with five requirements:
- More than one writer, each with a public profile page.
- Our own domain and full control of the content.
- A cost as close to zero as possible. Ideally, the domain renewal is the only bill.
- Pages that load fast, even on a phone with a slow connection.
- Writers who never need to open a terminal.
The last point was probably the one that affected the setup the most. We wanted the content to stay as Markdown in Git, because we like having plain files as the source of truth, but we didn’t want every writer to work with those files through a terminal. That’s why the editing layer matters. The site can stay static and Git-based underneath while writers still get a normal browser editor.
The stack
| Layer | Choice | Why |
|---|---|---|
| Site | Astro, static output | HTML and CSS out, no server to run |
| Content | Markdown in Git | Plain files we can move anywhere |
| Editor | Pages CMS | A browser editor that commits to the repository |
| Host | Cloudflare Pages | Free static hosting with a custom domain |
| Checks | GitHub Actions | Type and content checks on every push |
The thing we like most about this stack is that the content isn’t tied to any one tool. Astro reads Markdown. Pages CMS edits Markdown. Git stores Markdown. Cloudflare only serves the result.
If one of those tools stops working for us later, the posts are still just files in a repository. We care about that a lot more than having a CMS with every feature imaginable.
Why static, and not a database
A blog gets read far more often than it gets updated, so we didn’t see much reason to put a database behind it. Astro builds each post into a file once, and Cloudflare serves that file directly. There’s no database to maintain, no application server to keep running, and not much happening at request time.
We like that because there’s very little runtime complexity. Once the build is done, the site is basically just files.
The tradeoff is that a new post isn’t live until a new build finishes. For this kind of site, we’re completely fine with that. We publish occasionally, not every few seconds, so waiting for a build is barely a cost. If this were a product with user accounts, comments, or data changing constantly, we’d probably make a different decision.
Why Pages CMS, and not Keystatic
We compared Pages CMS and Keystatic because both solve the same basic problem we had: we wanted a browser editor while keeping the content in Git.
Keystatic is really good, but the version we were considering runs an admin route as part of the site. That means the site itself is no longer purely static, and it also means keeping authentication as part of the application.
Pages CMS takes a different approach. It lives outside the site, reads a .pages.yml file from the repository, and writes the content back through the GitHub API.
That fits this project better. Astro still sees normal Markdown files, Git is still the source of truth, and the final site is still completely static. Pages CMS is just the editing interface sitting on top of that.
Writers sign in with GitHub, and the people who can publish are the same people who already have write access to the repository. We don’t need a separate authentication system just for the blog.
The obvious downside is that the editor lives on someone else’s service. We’re fine with that because the content doesn’t. If Pages CMS disappears, nothing needs to be migrated. The Markdown files are still in Git and can be edited with something else.
Content that fails the build
Every post and every author profile has to pass a schema. We wanted content errors to show up before deployment instead of turning into broken pages later.
title: How we built this blog
description: The stack behind dotko.id, and the decisions that shaped it.
publishDate: 2026-08-28
author: dianyehezkiel
tags: [engineering, design]
The schema defines what fields exist, what format they should use, and some basic rules around them. A missing description can fail the build. A date in the wrong format can fail the build. A post referencing an author that doesn’t exist can fail the build.
It felt a little strict when we first set it up, especially for a small blog, but we’re glad we did it. It has already caught content mistakes that would otherwise have gone live.
A failed build also doesn’t take the site down. Cloudflare keeps serving the last successful deployment, so the bad content simply never reaches production.
Design: technical, without pretending to be a terminal
We wanted the site to feel technical, but we didn’t want to turn the whole thing into a terminal-themed website. There are enough developer sites with fake prompts, blinking cursors, and green text on black already.
So we kept the technical feel mostly in the typography. Navigation, titles, dates, tags, labels, and code use IBM Plex Mono. The actual article text uses IBM Plex Sans.
We originally used a serif font for the body. It read well on its own, but once everything was together, the page started leaning more toward an editorial magazine look than what we wanted. The serif and monospace fonts also never really felt like they belonged to the same system.
Switching the body to IBM Plex Sans fixed that for us. It belongs to the same superfamily as IBM Plex Mono, so the two fonts feel related without making everything look the same.
A few other decisions followed the same idea:
- Two backgrounds, one accent. Light mode uses a warm paper tone. Dark mode is near black. One green accent works across both.
- Lists are rows, not cards. A post listing shows the date, title, and one line of summary. We don’t think every article needs a thumbnail just because the layout can fit one.
- Cover images are mostly for links. They show up in social previews and search results, but the article page itself stays focused on the text.
- Initials instead of fake portraits. We tried generated placeholder portraits first. At avatar size, they looked more like broken images than intentional placeholders, so we removed them.
- The theme follows the system by default. If you manually choose light or dark mode, the site remembers it.
- Section headings get a
##marker. It’s added through CSS and is just a small reference to the Markdown underneath the site.
None of these things are complicated, but together they make the site feel closer to what we had in mind.
Build the same thing
If you want roughly the same setup, the process is pretty straightforward:
- Create an Astro project with static output and strict TypeScript.
- Define content collections for posts and authors, and give each one a schema. Make invalid content fail the build.
- Build the main pages: home, a paginated post list, individual posts, tag pages, and an author profile page for each writer.
- Add a feed, sitemap,
robots.txt, canonical URLs, and social metadata. Keep the site origin in one place. - Put images in the source tree so Astro can process and resize them during the build. Use one consistent convention for image paths.
- Add a
.pages.ymlfile that describes the same content fields exposed by the Astro schema. - Add GitHub Actions to run checks and a production build on every push.
- Connect the repository to Cloudflare Pages, configure the build command and output directory, and attach the domain.
The Astro part is pretty straightforward. The part that needs more care is keeping .pages.yml in sync with the content schema.
They describe the same content from two different sides. The Astro schema decides what the site accepts, while .pages.yml decides what writers can enter through the editor. If they drift apart, Pages CMS can produce content that Astro refuses to build.
It’s a little repetitive, but we prefer that over introducing a bigger content system just to avoid maintaining two small definitions.
What it costs
At our current size, basically just the domain.
Cloudflare Pages, GitHub, Pages CMS, and GitHub Actions all fit comfortably inside their free tiers for what we’re doing. That could obviously change later, and we don’t really assume any free service will stay free forever.
The reason we’re comfortable with the stack isn’t that every line on the pricing page currently says zero. It’s that none of those services owns the important part.
The important part isn’t that the stack is free. It’s that the content is Markdown in Git. If one part becomes expensive, annoying, or disappears completely, the content doesn’t need to move with it.
That’s probably the main idea behind the whole setup. Keep the content portable, keep the deployed site simple, and only add more moving parts when there’s a real reason to.