Skip to content

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:

pip install mkdocs-material

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
# Project Information
site_name: 'Hypertext Dispatches'
site_description: 'Hypertext Dispatches by Ryan Johnson'
site_url: https://tenthirtyam.org
site_author: Ryan Johnson
docs_dir: docs
site_dir: .site
use_directory_urls: true

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 serve

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 in extra_css. Clarity City for prose, Fira Code for code blocks.
  • features: This list is where the really useful behaviors live:
  • content.code.copy adds a copy button to every code block.
  • navigation.instant makes page transitions feel instant with prefetching.
  • navigation.tabs.sticky keeps the top-level tab bar visible while scrolling.
  • search.highlight highlights 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

authors:
  tenthirtyam:
    name: Ryan Johnson
    description: 'Husband, father, and avid technologist based in Tallahassee, Florida'
    avatar: http://github.com/tenthirtyam.png

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:

pip install mkdocs-material
docker pull squidfunk/mkdocs-material

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 the git-revision-date-localized plugin 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 like v6.0.2 is 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 the gh-pages branch, 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

mkdocs-git-revision-date-localized-plugin==1.5.1
mkdocs-minify-plugin==0.8.0
mkdocs-open-in-new-tab==1.0.8
mkdocs-glightbox==0.5.2
mkdocs-video==1.5.0
neoteroi-mkdocs==1.2.0
mkdocs-table-reader-plugin==3.1.0

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.

tenthirtyam.org  CNAME  tenthirtyam.github.io

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

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": [
    "config:recommended"
  ]
}

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:

  1. Create a Markdown file in docs/posts/ with the date and slug in the filename:

    docs/posts/2026-03-21-my-post-title.md
    
  2. Add front matter at the top of the file:

    ---
    title: "My Post Title"
    date: 2026-03-21
    authors: [tenthirtyam]
    categories:
      - Technology
    tags:
      - Some Tag
      - Another Tag
    ---
    
  3. Write the content in Markdown, using any of the enabled extensions.

  4. Add a <!-- more --> marker after the opening paragraph to set the excerpt boundary that appears on the blog index.

  5. Preview locally with mkdocs serve. The live-reload server handles watching for changes and refreshing the browser.

  6. 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 like https://<branch-name>.tenthirtyam-github-io.pages.dev, and posts that URL back to the pull request.

  7. 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.

  8. 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 at tenthirtyam.org shortly 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.