Redesigning the Blog: From Risotto to Relearn
Why Change Anything?
The blog ran on the Risotto theme since day one. I like Risotto — it’s clean and minimal — but as the post count crept past ninety, a single flat content/posts/ list stopped scaling. I wanted proper topic navigation, a homepage that surfaces recent writing, and a look closer to devopstoolkit.live, which is built on hugo-theme-relearn.
So the goal became: migrate to Relearn, borrow its structure and visual system — dark, high-contrast, single accent color — but keep it clearly my site. My avatar, my Kavafis tagline, my links.
The two hard rules:
- Don’t lose any content.
- Don’t break any existing URL.
What follows is the whole journey — the migration itself, and the optimization and polish passes that came after. It turned into a surprisingly long list of gotchas, so consider this a field guide for anyone pointing Relearn (a documentation theme) at a blog.
Installing the Theme (and a Shallow-Clone Gotcha)
Relearn goes in as a git submodule, pinned to a tagged release rather than main. Except git submodule add timed out — Relearn’s repository carries a lot of history (docs site, screenshots, every release). The fix is a shallow clone at the tag, then registering it as a submodule:
absorbgitdirs moves the nested .git into the parent’s .git/modules, so it behaves like a normal submodule from then on. If this sounds familiar, it’s the same family of pain as not initialising your submodules on clone.
A Custom Color Variant
Relearn’s theming is refreshingly sane: every variant is a plain CSS file at assets/css/theme-<name>.css that sets custom properties. You @import a shipped variant and override what you need.
Mine is based on zen-dark — its modern single-topbar layout derives links, search and the active menu item from --PRIMARY-color, so one variable cascades everywhere. The first iteration shipped as Aegean Teal (#2dd4bf) on near-black with dracula code blocks; it now runs full gruvbox-material — and because the variant is one CSS file of variables, the entire restyle was a single-file swap:
The classic gruvbox split — green for interface accents, orange for highlights, red for inline code, yellow for search hits — maps neatly onto Relearn’s primary/secondary/accent variables. The syntax theme isn’t shipped, but Hugo generates any Chroma style:
The Gotcha That Cost Me Ten Minutes
Back in the first (dracula-era) iteration, a build failed with:
I was using --CODE-theme. The culprit was a comment: I had written the chroma stylesheet’s filename on that line. Relearn scans the variant file for @import ... chroma-*.css, and its regex is non-greedy across newlines — it matched my legitimate @import "theme-zen-dark.css" and ran down to the literal filename in the comment. Reworded the comment, build went green. Watch what you put in your CSS comments.
The Font — and a Greek Glyph Lesson
The whole site wears one typeface, driven through Relearn’s own font variables:
This too is a second act. The first font was Departure Mono, a pixel font with real character — but no Greek glyphs, so the Kavafis tagline (“Σα βγεις στον πηγαιμό για την Ιθάκη…”) had to opt out to a serif or render as fallback soup. JetBrains Mono covers Latin, Greek and Cyrillic, so the tagline now joins the site font in italic. Know your font’s character coverage before you commit to it.
The font is self-hosted (Regular, Bold, Italic — ~284 KB of woff2 total) rather than pulled from Google Fonts: no third-party request, no consent question, and it rides the same immutable-cache headers as everything else.
The theme’s heading scale also needed compressing — Relearn’s 3.25rem h1 is enormous on an all-mono site. It now runs h1 2.2 / h2 1.65 / h3 1.35 / h4 1.15, which also fixes a theme quirk where h4 was larger than h3.
Restructuring 96 Posts Without Breaking URLs
The old layout was one flat section. The current one is six topic sections:
I first merged Networking and Automation into one section, then un-merged them later — routing deep-dives and automation tooling are genuinely different audiences. Every move, both times, kept every prior URL working through front-matter aliases:
That’s the whole ballgame: Hugo turns each alias into a tiny redirect page. The site now carries 111 redirect pages — original /posts/ paths, the interim merged-section paths, and the pre-hyphenation slugs (a later hygiene pass renamed twelve snake_case/MixedCase bundle directories like Shebang_bash_security to shebang-bash-security). Zero broken bookmarks through three rounds of restructuring.
The Broken-Figure Discovery
While migrating I found that ~30 posts referenced their images as {{< figure src="/images/diagram.png" >}} — an absolute path from the Risotto/static days. The images actually live inside each post’s leaf bundle, so every one of those figures had been quietly 404ing. Bundle-relative paths (src="images/diagram.png") fixed thirty posts’ worth of invisible diagrams in one commit. Audit your image paths when you change content structure.
A Homepage That’s a Feed
Relearn’s default homepage is a chapter list. I wanted the devopstoolkit rhythm: latest posts with a thumbnail, teaser and a Full article » link. That’s a layouts/index.html override reusing the theme’s block structure (sidebar, topbar and search survive) rendering the twelve newest posts across all sections.
Thumbnails: Category Cards, Not Pot Luck
The feed originally pulled the first image out of each post — screenshots next to diagrams next to placeholders, a patchwork. Now every card shows its category thumbnail: a branded #282828 SVG with the section’s emoji, an orange #fe8019 label and border, and a muted credit line. Consistent system, and a thumbnail.png dropped into any bundle still overrides it.
Two emoji lessons from building those cards:
☸and⚙are monochrome text glyphs, not color emoji — with nofillthey rendered black-on-dark, i.e. invisible. Full-color emoji (☸️with the variation selector,🛠️) plus an accent-coloredfillfallback fixed it.- The favicon is now a 🚢 in a tiny SVG — and it turned out Relearn only auto-detects favicons in
static/images/, so the site had been shipping no favicon link at all while the.icosat unnoticed instatic/root.
Killing the External Search
The old setup queried an external Meilisearch instance, fed by a build-time index.json and a CI step that pushed documents after every deploy. Relearn ships offline, in-browser search, so all of that came out: the indexing steps in both CI pipelines, the output template, the custom search page, and the public search key baked into the HTML. One fewer service, one fewer secret.
One catch discovered later: Relearn loads its generated search index — 688 KB for this site — on every page view, at DOMContentLoaded. For a docs site where everyone searches, fine; for a blog, it’s the single biggest asset on the page and most visitors never touch it. A small shadow of the theme’s search.js now defers the index until the first focus on the search box (loading immediately on the search page and deep links). Same UX, ~700 KB less per page.
The Performance Pass
With the structure settled, a dedicated optimization pass went after weight. The headline numbers:
| Metric | Before | After |
|---|---|---|
| Homepage full fetch | ~1.75 MB | 420 KB |
| Heaviest post’s images | ~8 MB | 248 KB |
| Worst single image | 1.7 MB JPG | 40 KB WebP |
| Search index | every page | first search |
| Avatar | 672 KB PNG | 21 KB WebP |
The image work is Hugo-native — no external tooling. A render-image hook override (and a matching figure shortcode) routes every raster page resource through Hugo Pipes: capped at 1440px, converted to WebP q80, with explicit width/height attributes so nothing shifts while loading. The processed permalink is handed back to Relearn’s own image partial, so the theme’s lazy-loading and lightbox keep working untouched:
Resize vs Fill
One self-inflicted wound: processing the avatar with resize 384x384 stretched it — Hugo’s resize forces exact dimensions and ignores aspect ratio. The old plain <img> had been quietly rescued by CSS object-fit: cover. The fix is fill 384x384 center, which scales then center-crops — the processing-time equivalent of object-fit: cover. If your source isn’t square, resize WxH is almost never what you want.
Polish: the Details a Docs Theme Doesn’t Give a Blog
A final UX/SEO pass, mostly small overrides:
- Reading time + date under every post title; “More in <section>” at each article’s foot (five newest same-section posts), so a Kubernetes deep-dive leads to the next one instead of dead-ending.
- Code language labels — a small accent-colored
data-langtag pinned to each block’s corner, fading out on hover where the copy button appears. - 404 page — the theme’s default renders a shrug SVG with hardcoded black fill: invisible on a dark variant. The override lists all six sections with post counts.
- RSS was broken in the funniest way: Relearn’s docs-oriented feed template renders the homepage’s child pages — which for a blog is six section stubs and zero posts. Anyone subscribed got a feed that never updated. A one-line shadow of
index.rss.xmlfeeds itsite.RegularPagesnewest-first instead. - SEO: canonical links,
og:image(the post’s first image, avatar fallback), andTechArticleJSON-LD on every post.
The CI Pipeline: 6m11s → 75s
The deploy pipeline (GitHub Actions → multi-arch image → GHCR → Trivy → Cosign → GitOps commit → Flux rollout to the k3s cluster — the full story is in Hugo Blog — Full CI/CD GitOps) was taking over six minutes. Two steps ate nearly all of it:
| Step | Before | After |
|---|---|---|
| Checkout (full history + submodules) | 168s | 6s |
| Build & push image (multi-arch) | 151s | 20s |
| Total pipeline | 6m11s | ~75s |
The fixes:
fetch-depth: 1— Hugo doesn’t use git history and the metadata action only needs HEAD’s SHA, yet the checkout was pulling the full history of the repo and the 57 MB theme submodule.- Build Hugo once, on the runner. The Dockerfile ran
hugoinside the multi-arch image build — meaning the arm64 half executed Hugo under QEMU emulation, painfully slowly, to produce byte-identical static files. Now a slimDockerfile.cijust copies the runner-builtpublic/into nginx for both architectures. The arm64 image build does zero emulated work.
If your multi-arch image build is slow, ask what actually needs to run per-architecture. For a static site: nothing.
Lessons Learned
- Aliases are your safety net. Three rounds of restructuring, 111 redirects, zero broken URLs. Restructure fearlessly, redirect religiously.
- A docs theme has docs assumptions. Search index on every page, RSS listing “chapters”, a chapter-list homepage — each needed a small, surgical override for blog semantics.
- Verify what actually renders. Invisible black-on-black glyphs (twice: thumbnails and the 404 page), figures 404ing for months, a favicon that was never linked — none of these show up in a passing build.
- Know your tools’ sharp edges: Hugo
resizedistorts wherefillcrops; Relearn’s chroma regex reads your comments; emoji come in text and color flavours; not every mono font speaks Greek (our first didn’t). - Variables make restyles cheap. Because the look lives in one variant file of custom properties, swapping the whole site from teal-on-near-black to gruvbox-material was a one-file change — the placeholders and a font swap were the only other moving parts.
- Shadow, don’t fork. Every theme override here is a single file with a “re-diff on upgrade” comment — the submodule stays pristine at 9.0.3.
- Measure before optimizing. The audit found the real costs (search index, one avatar, a handful of camera-roll JPGs) in places I wouldn’t have guessed — and the CI bottleneck wasn’t Hugo at all, it was git history and QEMU.
The map changed; the journey didn’t. Ithaka’s still the point.