Skip to main content
Engineering Notes · May 2026

Boring by Design — A Revamp

OCBC Odyssey China Immersion in Shanghai.

I rebuilt my personal site again. I’ve rented this virtual home for two-plusdecades, and rebuilding it every few years is the pattern, not the exception.There’s no ambition here — no audience target, no monetization plan, nocareer narrative. It just felt like time, and a quiet evenings during the OCBC Odyssey China Immersion in Shanghai gave me the excuse.

Personal sites are odd things. The only honest reason I keep this one running is that it’s fun. Tinkering with templates, picking fonts that are slightly wrong on purpose, watching a build pipeline produce a static file — there’s a small dopamine loop in it that I don’t seem to get tired of.

The other reason is frontend. I’ve been a backend and infrastructure person my whole career; CSS is not my forte. But visual work is entertaining in a way that load-balancing and migrations aren’t, and rebuilding this thing every few years is how I keep up with what the browser can actually do these days.

What I landed on this round is unremarkable, on purpose.

  • Hugo for the static-site generator — migrated from an npm-based pipeline.
  • GitHub Pages for the host — no changes.
  • Cloudflare in front — no changes.

No backend, no database, no JS toolchain, no CMS — the whole site is adirectory of Markdown files and a Makefile.

And oh, virtually zero cost. No hosting bill, zero SSL fee, zero CDN tier, zero subscription anywhere in the path. And free in attention — no auth boundary to mind, no database to migrate, no .env file, no cron jobs. Static-generated, sitting there, doing the job.

Why Hugo

I looked briefly at three other generators before picking Hugo: Eleventy, Astro, and Jekyll. They all ship Markdown to HTML — the differentiator wasn’t features, it was the toolchain.

Hugo is one Go binary. No package.json, no node_modules, no lockfile, no transitive supply-chain surface. Homebrew installs it in two seconds. There is no JS build step on this site at all — Hugo Pipes does CSS minification, JS minification, content-hashed fingerprinting, and SRI natively, no plugins required.

For a site I touch once every three years and want to still build cleanly on a fresh laptop years from now, one binary, one config file wins. The other three are fine projects — Eleventy has a great plugin ecosystem, Astro’s component model is genuinely lovely, Jekyll’s Ruby tooling is what most of GitHub Pages is built around — they just each bring a package-manager dependency I’d rather not carry for something this small.

The Submodule Knot

GitHub Pages “user sites” must be served from a repo named <username>.github.io. The standard pattern, to keep your source code separate from the published HTML, is two repos:

  • a source repo with content, templates, Makefile;
  • a Pages repo at <username>.github.io that just holds the built HTML and gets served.

The clean way to wire them together is to make the build directory inside the source repo (public/) a git submodule pointing at the Pages repo. Build into public/, push from inside the submodule (which goes live), bump the pointer in the parent. Tidy on paper.

Then I got bitten by Hugo’s --cleanDestinationDir flag.

The flag tells Hugo to clear stale files from the destination before each build. Excellent default. The catch: when the destination is a submodule, the clean step deletes the .git gitlink file that connects the working tree to the submodule’s repo. The submodule’s git data still exists at .git/modules/public/, but the working tree has been silently severed from it.

The failure mode is the fun part. After the build:

  1. git submodule status shows - <hash> public — the leading dash means not initialised.
  2. cd public && git status appears to work — but git can’t find a .git inside public/, so it walks up to the parent repo and reports the parent’s status. You think you’re inside the submodule. You’re not.
  3. The site you’ve just “deployed” hasn’t gone anywhere. Nothing to push.

I spent an evening chasing this before working out what was happening.

Don’t fight the convention

Don’t let Hugo’s destination be the submodule directory. Build into a sibling, then sync.

DIST   := dist
PUBLIC := public

build:
	hugo --minify --gc --cleanDestinationDir --destination $(DIST)

deploy: build
	@if [ ! -e $(PUBLIC)/.git ]; then \
		echo 'gitdir: ../.git/modules/$(PUBLIC)' > $(PUBLIC)/.git; \
	fi
	@git submodule update --init $(PUBLIC)
	@rsync -a --delete --exclude='.git' --exclude='.github' \
		$(DIST)/ $(PUBLIC)/
	@git -C $(PUBLIC) add -A
	@git -C $(PUBLIC) diff --cached --quiet || { \
		git -C $(PUBLIC) commit -m "deploy: $$(date -u +%Y-%m-%dT%H:%MZ)"; \
		git -C $(PUBLIC) push origin main; \
	}
	@git diff --quiet $(PUBLIC) || { \
		git add $(PUBLIC); \
		git commit -m "deploy: bump $(PUBLIC) submodule"; \
		git push; \
	}

Three pieces matter:

  1. --destination $(DIST) — Hugo writes into dist, which has no .git to wipe. --cleanDestinationDir is now safe.
  2. rsync ... --exclude='.git' — the sync into public is one-way, but explicitly leaves the submodule’s gitlink alone.
  3. The if [ ! -e $(PUBLIC)/.git ] guard — restores the gitlink if anything ever does remove it. Cheap insurance.

make deploy then does build → sync → commit/push the submodule → bump the pointer in the parent. One command, two pushes, site live.

Cloudflare, briefly

Cloudflare sits in front of GitHub Pages as a free CDN, free TLS terminator,and free DNS. Ten-minute setup: point the domain’s nameservers at Cloudflare,add records for the apex and www, turn on Always Use HTTPS, add a Page Rule for Cache Everything on static paths. Done.

On Lighthouse

Most of the speed is what isn’t there: no JS framework, no analytics blocking render, no chat widget, no tracking pixel, no client-side router. The whole main.js is ~9KB minified; post pages add another 0.5KB. There’s nothing to be slow.

A handful of small things on top:

  • <link rel=preload> for the post hero image (WebP), with fetchpriority="high" — Largest Contentful Paint lands on the actual image instead of arbitrary text.
  • <link rel=preconnect> for the two Google Fonts hosts, so DNS and TLS warm up in parallel with the rest of the page.
  • The Google Fonts stylesheet loads rel=preload + onload="this.rel='stylesheet'" so it doesn’t block first paint; a <noscript> fallback sits below for browsers without JS.
  • A tiny inline <script> in <head> sets data-theme="day|night" on <html> before first paint — no flash of the wrong theme on reload.
  • Every <img> has explicit width, height, and loading attributes. Cumulative Layout Shift is essentially zero.
  • A post-build script strips newlines from HTML outside <pre> and <script> blocks, shaving a few hundred bytes per page on top of Hugo’s tdewolff minifier. Vanity optimization, but it costs nothing.

The one piece worth showing the code for is the WebP companion. Hugo Pipes generates it at build time from whatever JPG or PNG the post’s front matter points to — no manual export, no separate cwebp step:

{{ $img  := resources.Get .Params.image }}
{{ $webp := $img.Process "webp q80" }}
<picture>
  <source srcset="{{ $webp.RelPermalink }}" type="image/webp">
  <img src="{{ $img.RelPermalink }}"
       alt="{{ .Params.imageAlt }}"
       fetchpriority="high" loading="eager" decoding="async">
</picture>

The webp q80 flag keeps the companion at quality 80 — small enough to win the LCP race, large enough that you can’t see the difference at viewing size. For one of the post heroes that’s 219KB JPG → 167KB WebP, content-hashed, fingerprinted, ready to cache forever. Modern browsers pick the WebP via the <source> element; older ones fall back to the <img>.

There are tradeoffs. The site leans on CSS animations — the metaball splatfield, the gradient H2 underline reveal, the cursor trail, the variousscroll-in fades — and on mobile they cost a few points on the Performancescore. Desktop pegs 100; mobile is more like high-90s with occasional dips when the animations stack on a slow CPU. I could rip them out and run flat 100s everywhere. I like the visual texture more than I like the extra two points.

That’s It

There’s no thesis here. I rebuild this site every few years for fun; the latest version uses some boring tools that fit together with one workaround documented above, and that’s the whole story. The boring stack works. If you want a low-stakes excuse to play with modern CSS, picking a small project where you do all the styling by hand is a surprisingly pleasant way to sneak the practice in.

Colophon

Two places I borrowed heavily from while building this:

  • Color Hunt — for the palette. I bounced through a dozen combinations before settling on the corporate-red and electric-cobalt pair the site uses now.
  • Codrops — for the learning and inspirations. The kind ofplace that quietly raises the floor for people like me who don’t write CSS for a living.