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 ProperDocs, themed with Materialx, 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. Merges to main trigger the deploy workflow when paths that affect the site change.

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 runs task build, uploads the static output, and GitHub Pages serves it. 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 ProperDocs Converts Markdown to a static HTML site
Theme Materialx Design, navigation, search, dark/light mode
CI/CD GitHub Actions + Task task install / task build, artifact upload, deploy-pages
Hosting GitHub Pages Serves the .site artifact (private repo, GitHub Pro)
Build Previews Cloudflare Pages Generates preview deployments for pull requests
DNS Cloudflare Custom domain management and TLS for tenthirtyam.org

ProperDocs

ProperDocs is a Python-based static site generator designed specifically for documentation. It reads a configuration file (properdocs.yml), picks up Markdown files from a directory tree, and emits a complete static HTML site.

Install it along with the Materialx theme:

pip install mkdocs-materialx

The core configuration lives in properdocs.yml at the root of the repository. Here is the site metadata and output configuration for this blog:

properdocs.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
site_dir: .site

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:

properdocs serve

ProperDocs starts a local development server at http://127.0.0.1:8000 with live-reload on every file change. Day to day I use Task via Taskfile.yml at the repo root:

# Install dependencies (creates `.venv` and installs Materialx + plugins)
task install

# Start local preview server
task serve

# Production-style build into `.site`
task build

A Makefile still mirrors install, serve, and build for fallback.

Taskfile.yml

---
version: "3"

vars:
  VENV: '{{.VENV | default ".venv"}}'
  BOOTSTRAP_PYTHON:
    sh: |
      if command -v python3.12 >/dev/null 2>&1; then
        command -v python3.12
      elif command -v python3.11 >/dev/null 2>&1; then
        command -v python3.11
      else
        command -v python3
      fi
  PYTHON: "{{.VENV}}/bin/python"
  PIP: "{{.PYTHON}} -m pip"
  DOCS: "{{.PYTHON}} -m properdocs"
  DOCS_PORT: '{{.DOCS_PORT | default "8000"}}'

tasks:
  default:
    desc: Show Tasks
    cmds:
      - task --list
    silent: true

  venv:
    desc: Setup Python Environment
    cmds:
      - "{{.BOOTSTRAP_PYTHON}} -m venv --clear {{.VENV}}"
      - "{{.PIP}} install --upgrade pip"
    status:
      - test -f "{{.VENV}}/bin/activate"
      - "{{.PYTHON}} --version"

  install:
    desc: Install MaterialX and Dependencies
    deps: [venv]
    cmds:
      - |
        if ! {{.PIP}} --version >/dev/null 2>&1; then
          echo "Rebuilding broken virtual environment at {{.VENV}}"
          rm -rf {{.VENV}}
          task venv
        fi
      - "{{.PIP}} install git+https://[email protected]/jaywhj/mkdocs-materialx@c088af2faa3a75dd4b9cded468eca285b6775d71"
      - "{{.PIP}} install --requirement .github/workflows/requirements.txt"

  serve:
    desc: Serve the Site
    cmds:
      - "{{.DOCS}} serve --open --livereload -a 127.0.0.1:{{.DOCS_PORT}} -w ./"

  build:
    desc: Build static site into .site
    deps: [install]
    cmds:
      - "{{.DOCS}} build"

Materialx

The Materialx 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 properdocs.yml.

Theme Configuration

properdocs.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'
    transmission:
      icon: material/radio-tower
      color: '#2b9b2b'
  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.code.annotate
  - content.code.copy
  - content.tabs.link
  - navigation.expand
  - navigation.footer
  - 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

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 Materialx's built-in blog plugin:

properdocs.yml: Blog
# 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
      post_url_format: "dispatches/{date}/{slug}"
      authors: true
      authors_file: .authors.yml
      archive: true
      archive_toc: true
      categories: true
      categories_allowed:
        - Personal
        - Songwriting
        - 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: 10
      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 properdocs 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, technologist, and aspiring songwriter based in Tallahassee, Florida'
    avatar: http://github.com/tenthirtyam.png
    url: https://tenthirtyam.org/about/
    email: [email protected]

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

Materialx unlocks a large set of PyMdown Extensions and standard Markdown extensions. These are the ones enabled here:

properdocs.yml: Markdown Extensions
# 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
    toc_depth: 3

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 `task serve` (or `properdocs serve`) instead of `task 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 `properdocs serve` locally.

Which renders as:

Pro Tip

Run task serve (or properdocs serve) instead of task 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 properdocs serve locally.

Example: Tabbed Content

=== "pip"
    ```shell
    pip install mkdocs-materialx
    ```

=== "Docker"
    ```shell
    docker pull jaywhj/mkdocs-materialx
    ```

Which renders as:

pip install mkdocs-materialx
docker pull jaywhj/mkdocs-materialx

Example: Mermaid Diagrams

```mermaid
graph TD
    A[Push to main] --> B[GitHub Actions]
    B --> C[task build]
    C --> D[GitHub Pages]
    D --> E[tenthirtyam.org]
```

Which renders as:

graph TD
    A[Push to main] --> B[GitHub Actions]
    B --> C[task build]
    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/**'
      - 'snippets/**'
      - 'properdocs.yml'
      - 'Taskfile.yml'
  workflow_dispatch:
concurrency:
  group: pages
  cancel-in-progress: false
permissions:
  contents: read
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0
      - name: Set up Python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: 3.x
          cache: pip
          cache-dependency-path: |
            .github/workflows/requirements.txt
            Taskfile.yml
      - name: Setup Task
        uses: tenthirtyam/setup-task@d179f8003b3fabb68608fd63d218495faa40f101 # v1.0.5
        with:
          version: latest
      - name: Install Dependencies
        run: task install
      - name: Build site
        run: task build
      - name: Upload Pages artifact
        uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
        with:
          path: .site
  deploy:
    runs-on: ubuntu-latest
    needs: build
    permissions:
      pages: write
      id-token: write
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0

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.
  • Task + artifact deploy: The build job installs Task, runs task install and task build (which emits HTML into .site), then uploads that directory with actions/upload-pages-artifact. A separate deploy job runs actions/deploy-pages, which publishes the artifact to GitHub Pages using the OIDC id-token flow. Nothing pushes to a gh-pages branch.

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 plugins are pinned in .github/workflows/requirements.txt:

Pinning these versions prevents surprise breakage from upstream changes. When you want to upgrade, update the version numbers, run task build locally to confirm, and open a pull request. Renovate handles automated update PRs.

GitHub Pages

Production HTML is whatever lands in .site after task build. GitHub Actions uploads that folder as a Pages artifact, and actions/deploy-pages publishes it. In the repository Pages settings, the source is GitHub Actions, not a gh-pages branch.

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 custom domain https://tenthirtyam.org is wired in the Pages settings and backed by a CNAME record in Cloudflare.

graph TD
    A[main branch\nMarkdown source] --> B[GitHub Actions\ntask build]
    B --> C[upload-pages-artifact\n.site output]
    C --> D[deploy-pages\nGitHub Pages]
    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 the same style of static build (properdocs build or equivalent), 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 TD
    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 task serve (or properdocs 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 properdocs 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 task install and task build, uploads .site, and deploy-pages publishes to GitHub Pages within about 90 seconds. The updated site is live at tenthirtyam.org shortly after the workflow completes.

That's the complete authoring loop. Production deploy stays automatic on merge. When I want to inspect output before pushing, I run task build and read the tree under .site. No FTP uploads and no gh-pages branch churn. 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 (ProperDocs, Materialx, GitHub Actions, GitHub Pages, and Cloudflare) is well-documented and straightforward to replicate.

Last Transmission

Updated May 15, 2026