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 MkDocs Material, 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.
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
.PHONY: docs-install docs-serve docs-serve-live docs-build docs-uninstall
docs-install:
pip3 install mkdocs-material
pip3 install --requirement .github/workflows/requirements.txt
docs-serve:
mkdocs serve
docs-serve-live:
mkdocs serve --livereload -w ./
docs-build:
mkdocs build
docs-uninstall:
pip uninstall mkdocs-material mkdocs -y
pip uninstall -r .github/workflows/requirements.txt -y
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: material
custom_dir: .overrides
language: en
favicon: favicon.ico
icon:
logo: material/radio-tower
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
- content.code.copy
- content.tabs.link
- 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
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
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
post_url_format: "dispatches/{date}/{slug}"
authors: true
authors_file: .authors.yml
archive: true
archive_toc: true
categories: true
categories_allowed:
- Technology
categories_toc: true
categories_name: Topics
categories_url_format: "dispatches/category/{slug}"
archive_name: Previously
archive_date_format: yyyy
archive_url_date_format: yyyy
archive_url_format: "dispatches/{date}"
pagination: true
pagination_per_page: 5
pagination_url_format: "dispatches/{page}"
pagination_format: "$link_first $link_previous ~2~ $link_next $link_last"
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
markdown_extensions:
- abbr
- admonition
- attr_list
- footnotes
- md_in_html
- neoteroi.spantable
- pymdownx.betterem:
smart_enable: all
- pymdownx.critic
- pymdownx.caret
- pymdownx.details
- pymdownx.emoji:
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
options:
custom_icons:
- .overrides/.icons
- pymdownx.highlight:
anchor_linenums: true
line_spans: __span
pygments_lang_class: true
use_pygments: true
- pymdownx.inlinehilite
- pymdownx.keys
- pymdownx.snippets:
check_paths: true
- pymdownx.superfences:
custom_fences:
- name: mermaid
class: mermaid
format: !!python/name:pymdownx.superfences.fence_code_format
- pymdownx.tabbed:
alternate_style: true
- pymdownx.tasklist:
custom_checkbox: true
- pymdownx.mark
- pymdownx.tilde
- tables
- toc:
permalink: true
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]/squidfunk/mkdocs-material.git
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.