my production build was 46 releases behind and nothing failed
cloudflare pages built this site with hugo 0.118 while i developed on 0.164 — and the config key i added to fix a bug was silently ignored for two days
the taxonomy pages on this site said Categories. capital c, in a site whose entire visual identity is that nothing is capitalised. i found the config key that controls it, set it, built locally, watched the heading go lowercase, committed, pushed, and went to bed.
the next morning it still said Categories.
not a cache. not a stale cdn edge. i hard-refreshed, i checked from another network, i deployed again. the fix was in the repo, the build was green, the deploy said success, and the page rendered exactly what it had rendered before.
the answer took an embarrassing amount of time to find and it wasn’t in my code at all. the machine building this site was running a version of hugo from 2023. mine was from last month. the key i had just added did not exist in the binary that built the page, and hugo’s response to a config key it has never heard of is to shrug and carry on.
the fix that worked everywhere except where it mattered
hugo title-cases automatically generated list titles by default. the docs are explicit about it ↗
: capitalizeListTitles is a bool that controls whether hugo capitalizes automatic list titles on section, taxonomy and term pages, and it defaults to true. so /categories/ gets a <title> of Categories, and there is nothing you can do about it from the content side because you never wrote that title — hugo generated it.
one line of config turns it off:
hugo.toml
capitalizeListTitles = falselocally, this worked. the heading went lowercase, the <title> went lowercase, and the term pages that had been rendering Ai and Mcp — hugo title-casing my tag names at me — went back to ai and mcp.
in production, nothing changed. and here’s the first genuinely disorienting part: production had never had the Ai / Mcp bug in the first place. the old hugo didn’t title-case term pages. so the symptom i had been staring at locally, the one that sent me looking for the config key, was a bug that only existed on my laptop. meanwhile the bug that did exist in production — the title-cased section indexes — was one i could no longer reproduce, because i’d just fixed it locally.
two environments, running the same commit, with two different sets of bugs, and no way to see either one from inside the other.
nothing failed, and that’s the design
i want to be fair to hugo here, because the instinct is to call this a bug and it isn’t one. it’s a choice, and it’s the same choice almost every configuration system makes.
hugo read a toml file containing a top-level key it had no handler for. it did not error. it did not warn. it did not print unknown configuration key: capitalizeListTitles to a log nobody reads. it ignored it, built the site, and exited zero.
that behaviour is load-bearing for forward compatibility — it’s what lets you write a config that works across versions, and what stops every deprecation from becoming a hard failure. but it also means a config file is not a contract. it’s a suggestion, and the binary decides how much of it to honour. you cannot tell, by reading your own repo, which of your settings are doing anything.
my build was green because there was nothing to fail. a green build is a claim that the build ran, not that it ran the thing you wrote.
46 releases isn’t a version gap, it’s a different program
local was hugo 0.164.0. the deployed site’s <meta name="generator"> — which hugo injects into the home page, and which i had never once looked at — said Hugo 0.118.2.
that’s roughly forty-six releases. and the reason it matters more than a number that size suggests is what’s inside the gap:
- 0.123.3 is where
capitalizeListTitleslanded. everything before it silently ignores the key. that’s my immediate bug, and it’s the boring part. - 0.146 overhauled the template system — the lookup rules, the reserved names, where hugo goes looking for a template for a given page. two hugos on opposite sides of that line don’t merely differ in features. they resolve templates by different rules. the same layout directory can produce different pages.
so the honest description of my situation was not “production is a bit behind.” it was “production is running a program that resolves my templates differently, and i have never once verified what it produces.” every conclusion i had ever drawn from a local build was, strictly, a conclusion about a different piece of software.
where 0.118.2 came from, and why i never chose it
i never picked that version. i never picked any version.
cloudflare pages ships a build image with a default version of every language and tool it supports, and if you don’t say otherwise you get whatever that image happens to carry. their current build image ↗
lists hugo at 0.147.7 — and this project was still being built by an older image, from back when the default was 0.118.2. the project was created, that image’s default came with it, and it stayed while i upgraded my laptop past it. (that image auto-migrates to a newer one in february 2027, which would have silently fixed this for me eventually — a version of my build changing under me with no commit either way, which is the same problem wearing a friendlier face.)
the override is one environment variable, HUGO_VERSION, set in the dashboard.
and now the part that i think is the actually interesting failure, rather than just an embarrassing one.
the setting that cannot live in my repo
cloudflare supports in-repo version files for a decent list of languages: .nvmrc and .node-version, .python-version, runtime.txt, .ruby-version, .swift-version. read that list looking for hugo and you will not find it. there is no .hugo-version. there is no .tool-versions covering it. hugo’s version on this project is exclusively a dashboard value.
which means:
- no commit in this repo can set it.
- no commit in this repo can prove what it currently is.
- anyone with dashboard access — including future me, at midnight, changing something unrelated — can change the version that builds my site without producing a diff anywhere.
i keep a docs/ directory in this repo specifically so that decisions are written down where the next person finds them. and the single most consequential input to my build is a value i can only document by asserting it, never by pinning it. that’s a genuinely uncomfortable thing to sit with, and i don’t have a clean fix for it. what i have is a habit, below.
the two fixes, and why i shipped both
fix one: pin it. HUGO_VERSION = 0.164.0 on the pages project, matching local exactly, set on production and preview both. preview matters as much as production — a preview build on a different version is a pre-production check that checks the wrong thing, which is worse than no check at all because you’ll believe it.
fix two: stop depending on the version. this is the one i’d argue for even on a correctly pinned project.
the reason /posts/ was the only index rendering correctly through all of this is that content/posts/_index.md had an explicit title in its front matter. it never needed capitalizeListTitles, because hugo only title-cases titles it generated itself. give it a title and there’s nothing to generate.
content/categories/_index.md
+++
title = "categories"
description = "everything on the site, grouped by what it's actually about"
+++three files, three explicit titles, and the bug is dead on every hugo version that has ever existed — including the one i didn’t know i was running. the config key stays too, because it’s correct on modern hugo and it’ll cover taxonomies i add later without me remembering this post. but it is no longer what the site depends on.
that’s the general shape, and it’s worth stating plainly: when a behaviour can be expressed either as a config flag or as explicit data, the explicit data survives a version change and the flag doesn’t. the flag is a request. the front-matter title is a fact.
proving it, instead of believing it
setting the variable is not the same as the variable working. so the verification was a real build, not a screenshot of a settings page: push a branch, open the preview deployment, and read three things off the live html.
- a
generatormeta reportingHugo 0.164.0— the version claim, read off the artifact rather than the dashboard. (grep for the bare word, not for the quoted attribute: the minify step below strips the quotes, soname=generatoris what actually ships.) - my table render hook firing. render hooks for tables landed in 0.134, so on the old binary the hook had been quietly inert this whole time — a second, unrelated feature i’d been shipping into a void.
- lowercase taxonomy titles, i.e.
capitalizeListTitlesfinally doing the job i’d added it for two days earlier.
this is the practice that actually changed. there’s no ci on this repo, deliberately — it’s a static site with one author, and a pipeline would be ceremony. but “no ci” quietly meant “local build is the only check,” and that assumption is exactly what broke. preview deployments cost nothing and touch nothing live — and they run the same image and env vars as production once you’ve set them the same, which is exactly why fix one had to be applied to both environments and not just to production. push the branch, read the preview, then merge.
i also found, while i was in there, that the build command was plain hugo rather than the hugo --gc --minify my own readme claimed. production html had never been minified. i changed it — as a separate deploy, after the version jump was confirmed working, because two output-format changes in one build make any regression ambiguous between them and you learn nothing.
what i’d take from this
- read your own
<meta name="generator">. or whatever your framework’s equivalent is. it’s one tag on the home page — hugo only injects it there — and it had been sitting in mine since the project was created. the information wasn’t hidden, it was just never looked at. - a config key your binary doesn’t recognise is indistinguishable from a config key that’s working. both produce a green build and no output. if a setting is important, verify the behaviour it’s supposed to produce, in the environment that matters — never the presence of the line in the file.
- pin every version your build depends on, and check the pin applies to preview environments too. a preview on a different version is worse than no preview.
- prefer the version-independent construct. an explicit front-matter title beats a config flag that only exists after 0.123.3. this is the same instinct as putting an invariant in a check constraint instead of a code review: push correctness down to the layer that can’t drift.
- if the pin can’t live in your repo, write down that it can’t. the dangerous state isn’t “configured in the dashboard.” it’s “configured in the dashboard and everyone assumes it’s in git.”
the fix was two settings and three front-matter lines. the lesson cost two days and is worth considerably more than that: for as long as this site has existed i had been verifying my work against a program that was not the one serving my readers, and every single build told me everything was fine.
next up: staying with the theme of things you shouldn’t take on trust — a create endpoint that read the owner’s id straight off the request body, and why the fix is the same one i keep arriving at from different directions. the server stamps who you are. you never ask.
if this saved you an afternoon, coffee is the going rate. no paywall, no tiers, no thank-you video.
$ ko-fi --send coffeeopens ko-fi.com. nothing is loaded from them on this page.