Inside the Stack: How This Blog Is Built¶
Every once in a while, someone asks me how this site works. The short answer: it is a static site generated by MkDocs, themed with Material for MkDocs, deployed to GitHub Pages via GitHub Actions, with Cloudflare Pages serving pull request build previews and Cloudflare handling DNS for the custom domain. No CMS, no database, no runtime server. Every page is a Markdown file in a Git repository. Everything runs automatically on every push to main.
This post walks through how all those pieces fit together, with the actual configuration files that run this blog today.
Disclaimer
This content is provided for historical reference and may no longer reflect current guidance or best practices.
Why a Static Site?¶
Before getting into the how, a quick note on the why.
I have run blogs on WordPress, Ghost, and a handful of other platforms over the years. They all work. They also all have a maintenance surface: server patching, plugin updates, database backups, security vulnerabilities, and the occasional corrupted post editor. Running a CMS is fine if the content volume justifies it. For a personal blog, it usually does not.
A static site generator trades all of that away in exchange for a constraint: every page must be renderable at build time. For a blog, that constraint is not a limitation at all. Posts are just Markdown files. Configuration is a YAML file. The entire site lives in Git. Rollbacks are git revert. Authoring is any text editor.
The operational cost is essentially zero. GitHub Actions does the build. GitHub Pages hosts the output. Cloudflare Pages generates build previews for pull requests. Cloudflare handles the DNS. The only things I maintain are the Markdown files themselves.
The Stack¶
| Layer | Tool | Role |
|---|---|---|
| Content | Markdown | Posts, pages, and structured content |
| Site generator | MkDocs | Converts Markdown to a static HTML site |
| Theme | MkDocs Material | Design, navigation, search, dark/light mode |
| CI/CD | GitHub Actions | Builds and deploys on every push to main |
| Hosting | GitHub Pages | Serves the static HTML output (private repo, GitHub Pro) |
| Build previews | Cloudflare Pages | Generates preview deployments for pull requests |
| DNS | Cloudflare | Custom domain management and TLS for tenthirtyam.org |
MkDocs¶
MkDocs is a Python-based static site generator designed specifically for documentation. It reads a configuration file (mkdocs.yml), picks up Markdown files from a directory tree, and emits a complete static HTML site.
Install it along with the Material theme:
The core configuration lives in mkdocs.yml at the root of the repository. Here is the site metadata and output configuration for this blog:
mkdocs.yml: Site Metadata
docs_dir points to the folder containing Markdown source files. site_dir is where the rendered HTML lands. use_directory_urls: true produces clean URLs like /dispatches/2026/my-post instead of /dispatches/2026/my-post.html.
To preview the site locally:
MkDocs starts a local development server at http://127.0.0.1:8000 with live-reload on every file change. The Makefile in this repository wraps the common commands:
# Install dependencies
make docs-install
# Start local preview server
make docs-serve
# Build the site
make docs-build
The Makefile target definitions:
Makefile
VENV ?= .venv
PYTHON := $(VENV)/bin/python
PIP := $(PYTHON) -m pip
MKDOCS := $(PYTHON) -m mkdocs
.PHONY: venv docs-install docs-installx docs-serve docs-serve-live docs-build docs-uninstall
venv: $(PYTHON)
$(PYTHON):
python3 -m venv $(VENV)
$(PIP) install --upgrade pip
docs-install: venv
$(PIP) install mkdocs-materialx==10.1.3
$(PIP) install --requirement .github/workflows/requirements.txt
docs-installx: docs-install
docs-serve: docs-install
$(MKDOCS) serve
docs-serve-live: docs-install
$(MKDOCS) serve --livereload -w ./
docs-build: docs-install
$(MKDOCS) build
docs-uninstall:
@if [ -d "$(VENV)" ]; then \
$(PIP) uninstall mkdocs-material mkdocs mkdocs-materialx -y || true; \
$(PIP) uninstall -r .github/workflows/requirements.txt -y || true; \
rm -rf $(VENV); \
else \
echo "No virtual environment found at $(VENV)"; \
fi
docs-deploy: docs-install
$(MKDOCS) gh-deploy --force
MkDocs Material¶
The Material theme for MkDocs is what makes this site look and behave the way it does. It provides responsive layout, automatic dark/light mode, rich navigation, full-text search, syntax-highlighted code blocks, admonitions, tabs, annotations, and much more, all driven from configuration in mkdocs.yml.
Theme Configuration¶
mkdocs.yml: Theme
theme:
name: materialx
custom_dir: .overrides
language: en
favicon: favicon.ico
icon:
logo: octicons/broadcast-24
admonition:
update:
icon: material/update
color: '#2b9b46'
history:
icon: material/history
color: '#9b2b9b'
copyright:
icon: material/copyright
color: '#2b9b9b'
heart:
icon: octicons/heart-24
color: '#9b2b9b'
lyrics:
icon: material/microphone
color: '#2b2b9b'
soundcloud:
icon: simple/soundcloud
color: '#ff7700'
palette:
- media: "(prefers-color-scheme)"
toggle:
icon: material/brightness-auto
name: Switch to Light Mode
- media: "(prefers-color-scheme: light)"
scheme: default
toggle:
icon: material/brightness-7
name: Switch to Dark Mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
toggle:
icon: material/brightness-4
name: Use Your System Preferences
font:
text: Clarity City
code: Fira Code
features:
- content.action.edit
- content.action.view
- content.code.annotate
A few things worth calling out:
palette: Three entries enable automatic system-preference-aware theming with a toggle to override it. The reader gets dark mode by default on a dark-mode OS, light mode on a light-mode OS, and can override it at will.font: Custom fonts are loaded from a separate CSS file inextra_css.Clarity Cityfor prose,Fira Codefor code blocks.features: This list is where the really useful behaviors live:content.code.copyadds a copy button to every code block.navigation.instantmakes page transitions feel instant with prefetching.navigation.tabs.stickykeeps the top-level tab bar visible while scrolling.search.highlighthighlights matched terms in the result pages.
The Blog Plugin¶
The blog is powered by Material's built-in blog plugin:
mkdocs.yml: Blog Plugin
- navigation.expand
- navigation.footer
- navigation.header
- navigation.indexes
- navigation.instant
- navigation.path
- navigation.prune
- navigation.sections
- navigation.tabs
- navigation.tabs.sticky
- navigation.top
- navigation.tracking
- search.highlight
- search.share
- search.suggest
- toc.follow
- toc.integrate
# Plugins
plugins:
- blog:
blog_dir: .
blog_toc: true
draft: false
draft_on_serve: true
draft_if_future_date: true
post_date_format: long
post_readtime: true
With draft_if_future_date: true, any post with a date in the future is treated as a draft in production builds and only rendered when running mkdocs serve locally. This lets you commit posts in advance without them appearing on the live site until their date arrives.
Author information lives in docs/.authors.yml:
docs/.authors.yml
Each post's front matter references the author key:
---
title: "My Post Title"
date: 2026-03-21
authors: [tenthirtyam]
categories:
- Technology
tags:
- GitHub Actions
- Automation
---
Markdown Extensions¶
Material unlocks a large set of PyMdown Extensions and standard Markdown extensions. These are the ones enabled here:
mkdocs.yml: Markdown Extensions
our web pages, and how they found our website. These cookies do not collect personal
information and are used for statistical analysis only. You can adjust your browser
settings to reject cookies if you prefer.
actions:
- accept
- manage
- reject
generator: false
social:
- icon: fontawesome/solid/rss
link: rss.xml
name: RSS
- icon: fontawesome/solid/paper-plane
link: mailto:ryan@tenthirtyam.org?subject=Hypertext%20Dispatches
name: Inbox Me
- icon: fontawesome/brands/bluesky
link: https://bsky.app/profile/ryan.tenthirtyam.org
name: Follow on Bluesky
- icon: fontawesome/brands/linkedin
link: https://linkedin.com/in/tenthirtyam
name: Connect on LinkedIn
- icon: fontawesome/brands/github
link: https://github.com/tenthirtyam
name: Follow on GitHub
- icon: fontawesome/brands/docker
link: https://hub.docker.com/r/tenthirtyam
name: Follow on Docker Hub
- icon: simple/soundcloud
link: https://soundcloud.com/39north
name: Follow on SoundCloud
extra_css:
- assets/stylesheets/Clarity-City.css
- assets/stylesheets/spanable.css
- assets/stylesheets/extra.css
# Extensions
markdown_extensions:
- abbr
- admonition
- attr_list
With these in place, you get:
- Admonitions (
!!! note,!!! warning,!!! tip) for styled callout blocks - Code annotations to attach inline explanations to specific lines
- Mermaid diagrams rendered directly inside fenced code blocks
- Tabbed content using
=== "Tab Label"syntax - Emoji shortcodes like
:material-github:rendered as SVG
Example: Admonitions¶
!!! tip "Pro Tip"
Run `mkdocs serve` instead of `mkdocs build` during local development.
The live-reload server refreshes the browser on every save.
!!! warning "Draft Posts"
Posts with a future date are hidden in production builds.
They only appear when running `mkdocs serve` locally.
Which renders as:
Pro Tip
Run mkdocs serve instead of mkdocs build during local development. The live-reload server refreshes the browser on every save.
Draft Posts
Posts with a future date are hidden in production builds. They only appear when running mkdocs serve locally.
Example: Tabbed Content¶
=== "pip"
```shell
pip install mkdocs-material
```
=== "Docker"
```shell
docker pull squidfunk/mkdocs-material
```
Which renders as:
Example: Mermaid Diagrams¶
```mermaid
graph LR
A[Push to main] --> B[GitHub Actions]
B --> C[mkdocs gh-deploy]
C --> D[GitHub Pages]
D --> E[tenthirtyam.org]
```
Which renders as:
graph LR
A[Push to main] --> B[GitHub Actions]
B --> C[mkdocs gh-deploy]
C --> D[GitHub Pages]
D --> E[tenthirtyam.org] GitHub Actions¶
The deployment workflow lives at .github/workflows/deploy.yml. It triggers on every push to main that touches the site source, and on manual dispatch:
.github/workflows/deploy.yml
name: Deploy to GitHub Pages
on:
push:
branches:
- main
paths:
- '.github/workflows/deploy.yml'
- '.github/workflows/requirements.txt'
- '.overrides/**'
- 'docs/**'
- 'mkdocs.yml'
workflow_dispatch:
permissions:
contents: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: 3.x
- run: |
git config user.name "Ryan Johnson"
git config user.email "[email protected]"
- run: |
python -m pip install --upgrade pip
pip install git+https://[email protected]/jaywhj/mkdocs-materialx@8ea355e0008e9a14a2175928ae710df32788bee3
pip install --requirement .github/workflows/requirements.txt
- run: mkdocs gh-deploy --force
A few design choices worth noting:
fetch-depth: 0: A full clone (not a shallow clone) is required for thegit-revision-date-localizedplugin to accurately report when pages were last modified. Without this, every file would show the same "last updated" timestamp.- Pinned action hashes: Each
uses:reference is pinned to a specific commit hash with the human-readable tag in a comment. This is a supply-chain hygiene practice: a tag likev6.0.2is a mutable pointer that can be silently moved, but a commit SHA is immutable. mkdocs gh-deploy --force: This single command builds the site and pushes the rendered HTML to thegh-pagesbranch, where GitHub Pages serves it from.
The paths: filter is important for efficiency. The workflow only runs when something that actually affects the output changes. Editing the README.md, for example, does not trigger a deployment.
The Python requirements for the MkDocs plugins are pinned in .github/workflows/requirements.txt:
.github/workflows/requirements.txt
Pinning these versions prevents surprise breakage from upstream changes. When you want to upgrade, update the version numbers, run the tests locally, and open a pull request. Renovate handles automated update PRs.
GitHub Pages¶
GitHub Pages serves the rendered HTML from the gh-pages branch. The mkdocs gh-deploy command takes care of the branch management automatically: it builds the site, commits the output to gh-pages, and pushes it. No manual branch management required.
This blog's source lives in a private repository. Publishing from a private repo to GitHub Pages requires GitHub Pro (or a GitHub Teams/Enterprise plan for organizations). With GitHub Pro, the repository can remain private while the Pages output is publicly accessible, which is useful when you want to keep draft posts and work-in-progress out of public view but still have a simple, free hosting solution.
The repository is configured with GitHub Pages set to serve from the gh-pages branch at the root path, mapped to https://tenthirtyam.org via a CNAME record in Cloudflare.
graph LR
A[main branch\nMarkdown source] --> B[GitHub Actions\nmkdocs gh-deploy]
B --> C[gh-pages branch\nrendered HTML]
C --> D[GitHub Pages\npublic site]
D --> E[tenthirtyam.org\nvia Cloudflare DNS] Custom Domain
GitHub Pages supports custom domains natively. You can add a CNAME file to your docs/ directory and configure the custom domain in your repository's Pages settings. DNS management then lives in Cloudflare's dashboard rather than your domain registrar.
Cloudflare Pages¶
Cloudflare Pages plays a specific role here: generating build previews for pull requests. It does not serve the production site; that is GitHub Pages' job.
When a pull request is opened, a GitHub Actions step (or the Cloudflare Pages GitHub App) triggers a Cloudflare Pages build. Cloudflare Pages runs mkdocs build, deploys the output to a unique preview URL like https://<branch-name>.tenthirtyam-github-io.pages.dev, and posts that URL back to the pull request. This lets you review exactly how a post or configuration change will look before merging it to main and publishing it live.
The workflow looks like this:
graph LR
A[Open Pull Request] --> B[GitHub Actions]
B --> C[Cloudflare Pages Build]
C --> D[Preview URL posted\nto Pull Request]
D --> E{Review OK?}
E -- Yes --> F[Merge to main]
F --> G[Deploy to GitHub Pages\nproduction]
E -- No --> H[Revise and push]
H --> B Build previews are ephemeral: they live for the lifetime of the pull request and are cleaned up when the branch is deleted. They are never indexed by search engines and are not linked from the live site.
Cloudflare DNS¶
Cloudflare manages DNS for tenthirtyam.org. The domain's CNAME record points to the GitHub Pages endpoint, and Cloudflare handles TLS for the custom domain.
This is a straightforward DNS delegation; there is no Cloudflare proxy (orange cloud) in front of the origin. GitHub Pages provides the TLS certificate for the custom domain natively, so Cloudflare's role here is purely DNS.
Renovate¶
Dependency updates are managed by Renovate, configured via renovate.json at the root of the repository.
renovate.json
Renovate opens pull requests when new versions of pinned dependencies are available: Python packages in requirements.txt, GitHub Actions in deploy.yml, and so on. This keeps the dependency graph current without requiring manual monitoring.
The Writing Workflow¶
With all of this in place, writing a new post looks like this:
-
Create a Markdown file in
docs/posts/with the date and slug in the filename: -
Add front matter at the top of the file:
-
Write the content in Markdown, using any of the enabled extensions.
-
Add a
<!-- more -->marker after the opening paragraph to set the excerpt boundary that appears on the blog index. -
Preview locally with
mkdocs serve. The live-reload server handles watching for changes and refreshing the browser. -
Open a pull request. Pushing to a branch and opening a PR triggers a Cloudflare Pages build via GitHub Actions. Cloudflare Pages runs
mkdocs build, deploys the output to a unique preview URL likehttps://<branch-name>.tenthirtyam-github-io.pages.dev, and posts that URL back to the pull request. -
Review the preview. The Cloudflare Pages preview shows exactly how the post will look in production (rendered Markdown, admonitions, diagrams, and all) before a single line merges to
main. -
Merge the pull request. GitHub Actions picks up the merge to
main, runs the build, and deploys to GitHub Pages within about 90 seconds. The updated site is live attenthirtyam.orgshortly after the workflow completes.
That is the complete workflow. No manual build steps, no FTP uploads, no deployment scripts to maintain. Markdown in, published site out.
The configuration files shown throughout this post are the actual files running this site today. If you want to build something similar, the stack (MkDocs, MkDocs Material, GitHub Actions, GitHub Pages, and Cloudflare) is well-documented and straightforward to replicate.