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:
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
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 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 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 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:
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 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. - Task + artifact deploy: The
buildjob installs Task, runstask installandtask build(which emits HTML into.site), then uploads that directory withactions/upload-pages-artifact. A separatedeployjob runsactions/deploy-pages, which publishes the artifact to GitHub Pages using the OIDCid-tokenflow. Nothing pushes to agh-pagesbranch.
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.
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
task serve(orproperdocs 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
properdocs 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, runstask installandtask build, uploads.site, anddeploy-pagespublishes to GitHub Pages within about 90 seconds. The updated site is live attenthirtyam.orgshortly 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