How This Blog Is Built and Hosted

· 8 min read

On this page

The problem #

I’m set in my ways, and I’m a private person. Ask anyone who has tried to get me onto a social network.

Then I started a second career, and it turns out this one comes with networking, a professional profile and the occasional public presentation. Apparently “keep your head down and fix things” doesn’t count as a personal brand.

I’m also a lifelong learner, whatever that means. In practice it means I can’t use a tool every day without wanting to know how it works underneath. I do believe in sharing what I learn, so writing it down in public is the obvious next step. Whether it makes me any more comfortable being visible is another question. We’ll find out together.

How I chose to solve it #

A blog, obviously. Being someone who will happily spend a weekend avoiding the easy option, I didn’t sign up for a blogging platform. I built it from the parts I use every day: a Linux compute instance in the cloud, Hugo to turn Markdown into a static site, and git to move it from my editor to the server.

I wanted a site with nothing to patch at runtime and nothing that could be logged into through it. A static site generator gives me that, and Hugo is a single binary I can pin to an exact version. I wanted the same from deployment: CI shouldn’t hold credentials for the server, so the server fetches its own updates.

So here it goes: my thoughts and thinking, out into the world for everyone to see.

What was hardest to grasp #

Not the technology, as it turns out. Templates, CI, a pull timer, a Content-Security-Policy: those are puzzles, and puzzles are the fun part. Everything in the next section, I’d happily do again.

The hard part was being public. Writing something down for myself is easy. Writing it knowing anyone can read it, judge it, and find the one command I got wrong is a different job. No amount of checksum verification helps with that.

So the hardest part to grasp wasn’t in the stack at all. It was getting comfortable with putting my name on something and letting it go.

How it works #

The pipeline at a glance #

  1. I push Markdown to main on GitHub.
  2. A GitHub Actions workflow builds the site and attaches it to a release called site-latest as site.tar.gz plus a SHA-256 checksum.
  3. On the server, a systemd timer checks that release every five minutes. When the checksum changes, it downloads the archive, verifies it, and syncs it into the web root.
  4. Caddy serves the files over HTTPS with a strict Content-Security-Policy.

The server is the only side that holds a credential, and that credential can read one repository and nothing else.

Building with a pinned Hugo #

Hugo changes its template structure between releases, and an older Hugo will happily fail on templates written for a newer one. So the version is pinned in the Makefile, and make downloads that exact release into bin/ and checks it against the published checksums before using it:

HUGO_VERSION := 0.167.0

$(HUGO):
	curl -fsSL -o bin/hugo.tar.gz \
	  https://github.com/gohugoio/hugo/releases/download/v$(HUGO_VERSION)/hugo_$(HUGO_VERSION)_linux-amd64.tar.gz
	curl -fsSL https://github.com/gohugoio/hugo/releases/download/v$(HUGO_VERSION)/hugo_$(HUGO_VERSION)_checksums.txt \
	  | awk '$$2 == "hugo_$(HUGO_VERSION)_linux-amd64.tar.gz" {print $$1 "  bin/hugo.tar.gz"}' \
	  | sha256sum --check --strict

My laptop and CI run the same binary, so “it built locally” means something. Nothing is installed system-wide, which matters on a machine whose packaged Hugo is several years old.

Three skins, one set of layouts #

The site has three looks: terminal, editorial and handbook. All the layouts, the series logic and the shared CSS live in a base theme. A skin is mostly one stylesheet that sets color tokens for light and dark mode, fonts and widths, and it can override any layout by putting a file at the same path. Switching skins is one line in hugo.toml:

theme = ["terminal", "base"]

Only one skin is live, but every skin has to build. make check builds all three with drafts included and treats any warning as an error, so a template change can’t quietly break the skins I’m not looking at.

The System / Light / Dark switch works without JavaScript. It’s three radio buttons, and the CSS picks colors with :has():

:root[data-theme="dark"], :root:has(#theme-dark:checked) { /* dark tokens */ }

A small script only remembers the choice between pages and visits, and applies it before the page renders so there’s no flash of the wrong theme.

Series as a content type #

Most of what I write is a series that builds one topic from the ground up, so series get their own structure. Each series is a folder with a landing page and numbered parts:

content/series/kvm-virtualization/
  _index.md                    # description, status, planned parts
  01-what-kvm-is/index.md
  02-building-a-host/index.md

The landing page lists parts I haven’t written yet. They show greyed out in the series contents and count toward “Part 2 of 8”, so a reader can see where a series is going before it gets there.

Publishing a release, not deploying #

Every push to main that touches the site runs one workflow. It runs make check and make build, then checks the output: the home page and RSS feed must exist, and no page may contain an inline script or a style= attribute. That last check exists because the server’s Content-Security-Policy would block them, and I’d rather find out in CI than from a broken page.

Then it packages the build and attaches it to a release:

tar -C public -czf site.tar.gz .
sha256sum site.tar.gz > site.tar.gz.sha256
gh release upload site-latest site.tar.gz site.tar.gz.sha256 --clobber

The workflow’s only permission is contents: write on its own repository, enough to update the release. It has no credentials for the server and doesn’t know where the server is.

Pulling on the server #

On the server, blog-site-pull.timer runs a short script every five minutes. It asks the GitHub API for the site-latest release, downloads the small checksum file, and compares it with the checksum of what’s deployed. If they match, it exits. That’s nearly every run.

When the checksum changes, the script downloads the archive, verifies it against the checksum, makes sure it contains an index.html, and syncs it into the web root:

echo "${wanted}  ${work}/site.tar.gz" | sha256sum --check --quiet
tar -xzf "$work/site.tar.gz" -C "$work/site" --no-same-owner --no-same-permissions
test -s "$work/site/index.html" || { echo "<3>build has no index.html" >&2; exit 1; }
rsync -a --delete --chmod=D0755,F0644 "$work/site/" "${docroot}/"

Any failure stops the script before rsync runs, so the site stays on the last good build. The <3> prefix makes journald log the message at error priority. That’s how an expired token shows up on my monitoring dashboard instead of sitting unnoticed in the journal at info level.

The script authenticates with a fine-grained GitHub token that has read-only access to this one repository’s contents. The systemd unit also limits what the script can touch: the filesystem is read-only except the web root and the script’s own state directory, home directories are hidden, and it can’t gain privileges.

Pulling instead of pushing turns the usual trust relationship around. CI never holds the keys to production. A compromised workflow could publish a bad build, but it can’t get a shell on the server. The worst a leaked server token can do is read a repository whose contents become a public website anyway.

Serving with Caddy #

Caddy gets TLS certificates from Let’s Encrypt on its own and serves the files with a few headers. The important one is the Content-Security-Policy:

default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self';
frame-ancestors 'none'; base-uri 'none'; form-action 'none'

The page can load scripts, styles and images from this site and nothing else. That’s only practical because Hugo bundles all the CSS into one stylesheet and the theme script into one file, both fingerprinted with a hash of their contents (site.min.<sha256>.css). Because the name changes whenever the content does, Caddy can tell browsers to cache them for a year without ever serving a stale stylesheet.

The infrastructure around it #

The server and its firewall are defined in Terraform. Everything on the host is configured by Ansible playbooks: Caddy, the pull script and timer, and the monitoring. The resume site and this blog share the server. Each site drops its own file into /etc/caddy/sites/, and the main Caddyfile imports them all. The pull script is a shared template, so adding a third site is a few variables.

Monitoring is a blackbox probe that checks the site answers over HTTPS, plus Caddy’s access log shipped to Loki. DNS is the one piece that lives outside the repository. It’s a pair of A/AAAA records at my domain registrar.

The whole server can be rebuilt from scratch with terraform apply and a handful of playbooks. The site itself never needs rebuilding on the server, because the next timer run fetches it from the release.

Where this leaves us #

If you’re reading this, I pushed to main, the timer did its job, and the hard part is done. The technical parts were never the problem.

Next up is a series on KVM, where I take apart the hypervisor I’ve been relying on for years without really knowing how it works.