The renewal notice is what did it.
Three hundred dollars a year for Ghost(Pro) Creator, to host 40 posts. No paid tiers, no Stripe account, no newsletter to speak of. The members list had four rows and one of them was me. I had turned off almost everything Ghost charges for and was still paying for all of it.
I use Ghost to write a post, hit publish, have Google index it, and have the link render as a card when I share it on LinkedIn or Bluesky. That is a static site. It has been a static site the whole time.
So I moved it. Hugo, Azure Static Web Apps, GitHub Actions, DNS staying exactly where it already was. Recurring cost is now the Azure DNS zone, six to twelve dollars a year depending on query volume, and everything else is free tier.
Most migration guides get this part wrong: not one post URL changed. No redirects for posts, no ranking risk on the search results and link shares that drive my traffic. This is the walkthrough, including the four or five places where my first plan was confidently wrong.
Your post URLs do not have to change
Ghost serves posts at https://www.techbyjeff.net/{slug}/. Hugo defaults to /posts/{slug}/. Nearly every Ghost to Hugo guide online accepts that difference and then spends a chapter teaching you to redirect 40 URLs.
Don’t accept it. Hugo’s permalink configuration will emit root-level URLs, and if you set it up before you convert anything, your post URLs come out byte-identical to what Google has indexed and what every link share you have ever posted points at.
Leave permalinks.section out and /posts/ stays live as a second listing page, thin duplicate content competing with your own homepage for the same terms. Pointing the section at /archive/ kills that and gives me back the archive page Ghost had.
permalinks.term changes only the URL. My posts still say tags:. Ghost uses the singular /tag/, Hugo defaults to the plural, and this one line reconciles them without rewriting 40 files.
Tag names and tag slugs are different fields
This one bit me first. It is specific enough that I would have missed it without checking.
Ghost stores a tag’s display name and its URL slug as independent fields. Hugo derives the URL from the name. Three of my eleven tags diverge, and all three are indexed:
| Tag name | Ghost URL (indexed) | Hugo would emit |
|---|---|---|
| Entra ID | /tag/entra/ | /tag/entra-id/ |
| Microsoft 365 | /tag/m365/ | /tag/microsoft-365/ |
| Active Directory | /tag/activedirectory/ | /tag/active-directory/ |
The other eight (news, homelab, powershell, windows, ai, aws, security, devops) round trip cleanly because their names and slugs already match.
The cheap fix is three 301 redirects at the host. The exact fix is a term branch bundle per divergent tag, which keeps the URL byte-identical:
Posts still carry tags: [Entra ID]. I took the bundle route and verified it on a live build, and all eleven tag URLs return 200.
One warning if you do the same: take the bundles or the redirects, never both. I had 301s for those three paths in an early draft of my host config alongside the bundles, which would have sent three working pages to /tag/entra-id/, a URL that does not exist.
Why Azure Static Web Apps
I looked hard at GitHub Pages and Cloudflare Pages before landing here. It came down to what Pages cannot do:
| GitHub Pages | Azure SWA (Free) | Cloudflare Pages | |
|---|---|---|---|
| Real 301 redirects | No, meta-refresh only | Yes, staticwebapp.config.json | Yes, _redirects |
| Custom cache headers | Not supported | Yes | Yes |
| Publish from a private repo | Needs a paid plan | Yes | Yes |
| DNS stays at Azure DNS | Yes | Yes, native integration | Apex impossible |
| Apex to www 301 | Automatic | Yes, but not where you will look for it | Apex unreachable |
| Cost | $0 | $0 | $0 |
GitHub Pages on a free personal account only publishes from public repositories, and it has no server-side redirect mechanism at all. Cloudflare Pages requires moving nameservers off Azure to serve the apex, which I did not want to do for a blog. SWA Free gives me real 301s, cache headers, a private source repo, and a portal flow that writes the apex validation TXT, the apex alias record, and the www CNAME into my existing Azure DNS zone. Two custom domains on Free: apex plus www.
The apex to www redirect, and the wrong turn I took looking for it. I went hunting for it in staticwebapp.config.json and concluded it was impossible, because route rules there match on path only: there is no hostname condition anywhere in the schema. That part is true. The conclusion I drew from it was wrong.
SWA does the redirect, just not as a route. Each custom domain can be set as the app’s default, and SWA then 301s every other hostname at it: the other custom domain and the generated *.azurestaticapps.net name alike. Set www as default and the apex 301s to it, matching what Ghost was doing. The generated hostname redirecting too is the giveaway that this is domain-level behavior, beyond what a config file can express.
One wrinkle in the portal: a new default will not take while an old one is set. Setting www as default against an apex that is already default fails with a flat Failed to set default custom domain: www.techbyjeff.net and no explanation. Unset the apex first, then set www. Two steps, and the error message tells you none of it.
The site works whichever hostname is default, so the cost shows up only in what you publish. baseURL is baked into every canonical, og:url, sitemap entry and RSS link at build time, so if it names the hostname that redirects, every URL you publish costs an extra round trip and Search Console files the lot under “Page with redirect”. Point baseURL at whichever hostname you made default, and check a sitemap URL with curl -o /dev/null -w '%{num_redirects}'.
Free tier caps bandwidth at 100 GB per month, a hard cap with no overage option, and carries no SLA. At my traffic that is three orders of magnitude of headroom, but there is no graceful degradation if Hacker News ever finds you.
Getting everything out of Ghost
Export before you touch anything. The content JSON is not everything, and that surprised me three times.
- Content JSON. Settings, Advanced, Import/Export, Export.
- Theme zip. You will not use it, but you may want to crib CSS from it. I did.
- redirects.yaml and routes.yaml. Settings, Advanced, Labs, Beta features. Not in the JSON export. The redirects carry link equity.
- Code injection. Settings, Code injection, both header and footer. Not in any export. Mine was doing a lot of work.
- Member email addresses. The content export has no
memberstable at all. Ghost Admin, Members (the top level sidebar item, a different screen from Settings, Members), gear icon, Export all members. - Post analytics CSV. Per-post performance, useful for deciding what to keep promoting.
- The content files archive from Ghost support. Email
support@ghost.organd ask for it.
Send that email. Mine came back at 168 MB compressed and contained 342 orphaned images totaling 76.7 MB: files I had uploaded at some point and later removed from a post. There is no other route to those, and once the subscription lapses they are gone permanently.
My export, parsed:
| Count | |
|---|---|
| Published posts | 40 |
| Published pages | 3 |
| Draft posts / pages | 5 / 2 |
Posts with non-empty html | 50 / 50 |
mobiledoc / lexical | 0 / 50 |
| Distinct images referenced | 225 (about 24 MB) |
| Tags | 11 public plus 1 internal |
That mobiledoc: 0 row is the reason I had to write my own converter.
Casualties of the move
Dropped on purpose: members, tiers, the newsletter, comments, Portal, Stripe (never connected), ActivityPub, Ghost’s built-in analytics, and the Ghost editor itself. My own analytics settled the fediverse question, since traffic comes from Google organic and from LinkedIn and Bluesky link shares, with the Network tab contributing nothing measurable. The newsletter had three real subscribers and they signed up to know when I publish, which RSS does.
Lost for good: post revision history is not in the export, so git becomes revision history going forward, which is a straight upgrade. Web traffic history lives in Ghost’s analytics workspace with no export path, so I start a fresh baseline. Automatic image resizing goes away in exchange for keeping image URLs stable.
Building the site
You do not need a Linux box for any of this. My first draft assumed one, left over from when self-hosting Ghost was still on the table. With SWA the build runs on GitHub’s ubuntu-latest runner, a disposable VM created and destroyed per push. Windows was my only machine in this plan.
Hugo is the only one you keep. Uninstall Python and pandoc once the content is converted; they are migration tooling and have no place in your publishing path.
One note that contradicts a lot of internet advice: the extended edition is no longer required for WebP or AVIF. Both work in standard now, and extended’s only remaining exclusive is LibSass, which Hugo deprecated in v0.153.0. Install extended anyway, it is a superset and costs nothing, but it is not about image formats.
Windows caps paths at 260 characters
My longest slug is 108 characters. Add content/posts/ and a .md extension and you are at roughly 125 characters before the site root enters the picture. Windows caps paths at 260 by default, so a repo checked out somewhere deep fails mid-conversion with a FileNotFoundError, after writing 28 files. It reads like a content bug.
Either keep the repo shallow, something like C:\src\techbyjeff\, or enable long paths once:
Windows only. The Actions runner is Linux and never hits it.
Theme choice
I picked PaperMod after building and testing the alternatives against my requirements.
It does not fight root-level permalinks, the highest-risk requirement, and I verified that on a real build. It has no Sass, no Node, and no Tailwind, just plain CSS and a single binary, so there is nothing to break in CI. Congo and Blowfish are both Tailwind based and Congo hard-requires the extended edition. PaperMod emits BlogPosting and BreadcrumbList JSON-LD out of the box, preserving the structured data my Ghost theme had.
One maintenance note, read from the git history: the last tagged release is v8.0 from September 2024, but master is active, having migrated to Hugo v0.146’s template system in May 2026 with commits this month. Track master and pin to a SHA.
Every post slug now shares a namespace with your pages
With posts at root, every post slug shares one namespace with /tag/, /archive/, /about/, and every top level page. A post slugged about silently overwrites your About page:
| |
Exit code 0. The build succeeds and one side wins at random. --panicOnWarning would catch it, but PaperMod emits unrelated deprecation warnings on Hugo 0.165 that would fail every build. Target collisions specifically:
Converting 40 posts
There is no maintained Ghost to Hugo converter. I checked before writing one.
| Tool | State | Verdict |
|---|---|---|
jbarone/ghostToHugo (Go) | v0.5.3, December 2020 | Dead. Parses mobiledoc, and Ghost 6 posts have mobiledoc: null. |
zerohate/ghost-to-hugo (Node) | 4 commits, no releases, not on npm | Weekend script pinned to a library last published in 2018. |
| Hugo’s own migration tools list | Checked this month | Ghost is not listed at all. |
Every one of my 50 posts is lexical with zero mobiledoc, which means the Go tool would have produced literally nothing. Converting the rendered html column is the only viable approach, because that field is populated regardless of editor format.
I wrote ghost2hugo.py. It never hands raw Ghost HTML to pandoc. Koenig cards get normalized in BeautifulSoup and replaced with tokens, pandoc runs on what is left, then the intended markdown or shortcode gets substituted back in. Pandoc never sees the cards, so it cannot mangle them, and shortcode braces never get escaped.
Running it:
| |
--permalink '/{slug}/' tells the script what Hugo will serve, so it correctly emits no self-aliases. Without that flag, the zero-redirect claim is aspirational.
The front matter it produces:
| |
173 image cards and nothing else
I built a card mapping table covering every Koenig card type before scanning what I had, which was the wrong order. All 50 posts came back with 173 image cards, 13 bookmark cards, and zero of everything else. No HTML cards, no embeds, no galleries, toggles, callouts, headers, or buttons. Every row in that table marked “needs manual cleanup” was moot, and the script’s riskiest code paths never executed.
Bugs the converter had to handle
Ghost’s kg-card-begin comments become visible text. Ghost wraps raw HTML regions in <!--kg-card-begin: html--> and <!--kg-card-end: html-->. Pandoc does not carry HTML comments through to GFM, it renders their text, so those surfaced as literal paragraphs reading kg-card-begin: html above and below every affected block. Fifty-four occurrences across 10 files, all rendering as stray visible lines and leaking into the articleBody JSON-LD. Strip them in BeautifulSoup before pandoc ever sees them, then verify with grep -rn 'kg-card-' content/.
A blanket site URL rewrite corrupts code blocks. My first version ran md.replace(site_url, "/") across the whole document, fenced blocks included, so curl https://www.techbyjeff.net/... in a tutorial became curl /.... Scope the rewrite to outside fenced blocks.
Classless <pre><code> becomes an indented block, not a fence. Pandoc emits four-space indented code when there is no class="language-*", so inject language-text first. I had 53 blocks with no language at all.
One myth I can retire
“Code blocks containing {{ break the Hugo build” is repeated all over the internet, and I asserted it myself against five named posts before checking. It is false. Hugo only treats {{< and {{% as shortcode delimiters. Bare {{ is ordinary text. A scan of every fenced block across all 53 files found 94 bare {{ and zero shortcode delimiters, and the build exits 0 with all five “affected” posts rendering correctly. Go template, Helm, and Jinja samples are safe as they are.
Diffing rendered text against the old site
The highest-value check in the migration, about fifteen minutes for all 40 posts. Diff the rendered text of each page, old against new:
Reading the markdown will not show you what went missing. This will.
An og:image mistake that would have cost me half my traffic
Half my traffic is LinkedIn and Bluesky link shares, so a missing Open Graph image carries a directly measurable cost: those posts render as a bare text link where every other post renders a card.
I had this wrong in a way that produced a passing build and a broken result. PaperMod reads .Params.cover.image, a nested map, and knows nothing about a featured_image: key. My converter emitted featured_image at first, and on a real build all 43 pages fell back to the site-wide card while zero used their own. After switching to --image-key cover, 41 of 43 use their own, the two exceptions being pages that get redirected away anyway. Forty-three branded cards or 43 identical generic ones, decided by one key name.
Verify after building:
A related trap: site-level images are not in any post, so a post-walking converter never sees them. My logo, favicon, and site-wide OG card all live in the export’s settings table. Losing your own favicon to a tooling gap is a silly way to start a new site.
Images: keeping the URLs buys less than I thought
Images go in static/content/images/YYYY/MM/file.png, which produces URLs byte-identical to Ghost’s. The alternative is Hugo page bundles, which would give me .Resize, automatic WebP, responsive srcset, and content-hashed filenames, the direct replacement for Ghost’s on-demand resizing. Files in static/ are copied verbatim and get none of that. It is a real tradeoff.
The usual argument for keeping the URLs is that image search traffic and hotlinks would break otherwise. Checking the live site made that argument much weaker than I had assumed. My rendered pages do not serve images from my domain at all: every <img src> points at Ghost’s storage CDN, and my own /content/images/... path returns a 301 to that CDN. Whatever Google Images indexed is a Ghost CDN URL, and those die when I cancel, under any host, in any layout. Keeping static/content/images/ is still right for a smaller reason: it turns today’s 301 into a 200 and preserves any hotlink that used the site-domain form.
Ghost stores internal URLs as __GHOST_URL__/content/images/..., so grepping for https:// finds nothing. My export had 895 references, all in that form, about 330 carrying a /size/wNNN/ segment that has to be stripped. And do not wget --mirror the site: Ghost serves responsive variants under /content/images/size/{dim}/, so mirroring harvests the srcset variants and not the originals, permanently importing downscaled copies of every screenshot you ever took.
Use the support archive instead. Mine yielded all 208 referenced images, 25 MB on disk, zero missing. Ship those and leave the orphans, the cached resizes, the bookmark thumbnails, and the five bundled Ghost themes in the zip as cold storage. Keep that zip backed up and out of your git repo: the export JSON inside it carries RSA private keys, session secrets, and your admin password hash.
Porting the code injection
This was not on my original checklist and it was doing more than I remembered. My Ghost header injection ran to 15 KB:
| What | Fate |
|---|---|
| Microsoft Clarity tag | Keep. Drop it into layouts/_partials/extend_head.html. |
| PrismJS from a CDN | Delete. Chroma replaces it server side and you drop two CDN round trips from every page. |
| Eleven numbered sections of CSS | Port selectively. |
The footer injection was a JavaScript shim that injected an Archive link. In Hugo that is a menu entry.
Every trap here came from one mistake: reading rendered output when the answer was in the two stylesheets that produced it.
The accent color is a decoy. Ghost stores an accent_color setting and defines --ghost-accent-color on every page, which makes it look authoritative. My theme never read it. Zero references in its stylesheet. Applying it would have invented a purple the blog had never displayed.
Listing and single post titles are different colors, because of an !important collision. The theme sets one color with !important; the injection sets another without it. A listing title is wrapped in an anchor so the theme wins; a single post title is a bare <h1> so the injection wins. Reproduce both. That is how the site looks today.
The override wins over the base value. Wherever the theme and the injection both set a property, the injection is the answer, and taking the theme’s number ships something visibly off.
Prism to Chroma is a remap. The token names do not survive, and Chroma splits several Prism tokens across many classes: string alone becomes eleven. Generate the dark and light stylesheet pair with Hugo itself, then paste your own hex values over the stock ones, so the contrast you tuned by hand is preserved:
| |
Chroma handles PowerShell better than I expected, correctly identifying arbitrary Verb-Noun cmdlets as builtins even though they are in no builtin list.
Redirects and cache headers at the host
staticwebapp.config.json goes in static/ so Hugo copies it to public/. Abridged, with one of each rule type:
| |
navigationFallback gives you sitewide soft 404s. Per the SWA docs, a navigationFallback rewrite returns HTTP 200. With {"rewrite": "/404.html"} and no exclude list, every dead or mistyped URL serves your 404 page with a 200 status. My post-cutover plan is to watch Search Console’s 404 report daily for a week, and that config disables the exact signal I would be monitoring. Drop navigationFallback and use responseOverrides, which preserves the real status code.
Use trailingSlash: "auto", not "always". Ghost 301s /about to /about/ today, and SWA’s default serves both with a 200, which is duplicate content on every URL, so you do need the setting. But always appends a trailing slash to files as well as folders:
| |
It still loads, but every image pays an extra round trip, and the URL that resolves without a redirect becomes .png/ when .png is what Google indexed. On a migration built entirely on URL preservation, that is the wrong default. auto gives folders a trailing slash and leaves files alone.
That one also produced a red herring: under always, images appeared to ignore the route-specific immutable cache header. They were not. The header I was reading belonged to the 301, because curl -I without -L never reaches the file.
Never list both /rss and /rss/. With trailingSlash set, SWA normalizes them to one route and rejects the entire config file:
That is deploy blocking. The file gets discarded, every redirect and header with it. Keep only /rss/, since a request for /rss gets normalized first and then matches.
Global max-age dropped from 3600 to 600. SWA Free has no CDN and no purge mechanism, so that number is purely how long a returning reader keeps seeing a stale page after you fix a typo. An hour is a long time to live with a bad <h1>.
The build workflow
| |
--baseURL is hard-coded, which guarantees canonical www URLs during cutover. Pagefind must run after hugo build and before deploy so public/pagefind/ ships. Without fetch-depth: 0, Hugo’s .Lastmod from git is wrong and pollutes the sitemap. And skip_api_build is not a valid input to that action despite appearing in plenty of examples: a real run warns Unexpected input(s).
Azure’s portal wires things up behind your back
Create the Static Web App in the Azure portal and it connects your GitHub repo whether you asked for it or not. A second workflow gets committed directly to main, so every push runs two deploy pipelines. The deployment secret is named after the app rather than the generic name every example uses, so my own workflow failed with deployment_token was not provided. And the portal’s workflow is configured for Oryx auto-build with output_location: "public", which cannot work when public/ is gitignored.
Delete the portal’s workflow and point yours at the secret Azure already created. Minting a second copy leaves two live deployment tokens for one app, and GitHub secrets are write-only, so you cannot read the first one back to rename it. The general lesson: if a provisioning UI offers a repo connection, assume it will write to your repo, and check git log and your secret list afterward.
Two records in a zone of seventeen
Use the portal’s custom domain flow here. The apex needs an Azure DNS alias record pointing at the SWA resource, because a CNAME is illegal at a zone apex and a plain A record hardcodes an address the service can change under you. The portal gets that right and doing it by hand is the fiddly part.
Before cutover, drop the TTLs. It is the only step with a lead time.
| |
Using PATCH here is deliberate. Dropping two TTLs in a zone that also carries live mail is exactly the kind of edit where a full PUT is a needless risk, because the record values ride along in the request body and a slip repoints mail. Then verify by counting: 17 record sets before, 17 after, exactly 2 at the new TTL.
On cleaning out old DNS records, delete exactly two. Advice to “clean out the old Ghost records” is actively dangerous stated generally. My zone has 17 record sets and only two of them are Ghost’s: the apex A record and the www CNAME. The rest are ProtonMail MX and DKIM, SPF and DMARC, Microsoft 365 autodiscover, Intune enrollment, a Google verification record, my Bluesky handle verification, and a CNAME for an unrelated app. Five of them carry my email. Deleting broadly there stops mail delivery.
Search, RSS, and three subscribers
Search is Pagefind, already wired into the workflow. In the theme, add data-pagefind-body to the post <article> and data-pagefind-ignore to the header and footer partials. Out of the box Pagefind indexes every page, including archive and tag pages, plus nav and footer chrome on every result.
PaperMod’s built-in Fuse.js search is the zero-effort alternative, but it fetches a single index.json containing the full text of every post and preloads it on every page: plausibly 300 to 600 KB on the critical path for every visitor whether they search or not. My traffic is organic search, and page weight feeds Core Web Vitals feeds ranking. Pagefind’s roughly 290 KB runtime loads lazily on first search only.
RSS lives at /index.xml with a 301 from /rss/. Hugo cannot natively serve a feed at exactly /rss/. The config that looks like it should work, path = 'rss', baseName = 'index', produces /rss/index.xml, and a static host answering a directory request looks for index.html, so /rss/ still 404s. Every major reader follows a 301 and most persist the new URL, so subscribers migrate transparently.
Analytics splits cleanly. Search Console and Bing Webmaster Tools first. They are server side, so adblockers are irrelevant, and only they can show real search queries: Google strips query terms from the referrer, so no client-side tool will ever see them. For the referral half I already had Microsoft Clarity in my Ghost code injection, so porting the tag into extend_head.html gave me analytics on day one with zero new accounts. Its data lives in Microsoft’s tenant, so that history survived the migration intact.
And the three subscribers. No major email service includes RSS-to-email on a free tier. Buttondown charges an extra $9 a month for it, MailerLite gates it to paid tiers, Kit starts at $33. That is $9 to $33 a month to serve three people, plus SPF, DKIM, and DMARC setup and GDPR data controller exposure. For N equals three, I email them myself: one line, three addresses in BCC, twice a month. The engineering instinct to automate this is the wrong instinct at this scale, because the automation costs more than the work it replaces.
Verification and cutover
The definitive migration test is one loop. Pull the live Ghost sitemap and assert 200 on every path against the new site:
| |
Note the loop over the child sitemaps. Ghost’s /sitemap.xml is a sitemap index: four entries pointing at the child sitemaps, with no post URLs in it. An earlier version of my runbook curled it directly, which passed while testing nothing at all.
Count the words in that loop. There are four child sitemaps and it lists all four, because the version I actually ran listed three. That omission is the subject of the next section.
Then the cutover: repoint apex and www, wait for the certificate (usually minutes), enable HTTPS enforcement, set the hostname in your baseURL as the SWA’s default custom domain, re-run the sweep against the real hostname, resubmit the sitemap in Search Console, validate a couple of URLs through the Rich Results Test, and test a LinkedIn and a Bluesky share.
Do not skip the default-domain step because the site looks fine without it. It decides which way the 301 runs, and if it runs against your baseURL every URL you publish redirects. This should return zero:
Change of Address in Search Console is not needed, because that is for domain or URL changes and this is neither. And do not build a sitemap ping into the workflow, because Google deprecated that endpoint in 2023.
Keep Ghost(Pro) running for two to four weeks after cutover. At roughly $25 it is the cheapest insurance in the project, and once you cancel the images and orphaned files are gone permanently. Rollback is trivial because you only ever read from Ghost, but record the two original record values before you change anything, so rollback never depends on Ghost still being reachable to look them up.
Five days later, the 404 report found what the sweep could not
The URL sweep went green. I cut over, resubmitted the sitemap, and left the analytics 404 report alone for a week. August 30 to September 4 it came back with eight distinct paths.
| Path | Views |
|---|---|
/author/jstuhr/page/1/ | 4 |
/tag/automation, /tag/bitwarden, /tag/itadmin, /tag/netapp, /tag/proxmox, /tag/synology, /tag/testing | 1 each |
All of it arrived with no referrer, which the analytics summary read as “check your social bios and email signatures for stale links.” That is the wrong conclusion. Every one of these is a Ghost-era path, and no-referrer direct traffic on old paths is what crawlers and bookmarks look like, not what a live link on someone else’s site looks like. The interesting split is elsewhere: one of these eight is a regression I caused, and seven are not.
The author archive was a real regression, and I talked myself out of it twice
Ghost publishes an author archive at /author/{slug}/, paginates it, and lists it in the sitemap. Mine was indexed. Two independent failures had to line up for it to reach production as a 404, and they did.
The sweep never fetched it. My loop ran over posts, pages and tags — three of the four child sitemaps. The fourth is sitemap-authors.xml, and I had a 301 for it sitting in my host config, so I knew perfectly well it existed. The one URL on the site that needed a redirect is the one URL the definitive test never saw. That is what makes it worth writing down: the test was not wrong, it was incomplete, and an incomplete sweep reports exactly the same green as a complete one.
Then I checked it by hand and checked the wrong thing. I curled /author/jeff/, got a 404 from Ghost, and wrote “nothing to do, already 404s” into the runbook. Ghost’s author slug is jstuhr. I had guessed a slug from a display name — one heading after the section where I worked out that Ghost stores names and slugs as independent fields, on the one field where I had just proved it to myself.
The fix is a single wildcard, because nothing on the Hugo site lives under /author/:
| |
That covers every slug and every /page/N/ at once.
The seven tag paths were never mine to break
Not one of them is among the eleven tags in my Ghost export. I retired them inside Ghost some time before the migration, and they have been 404ing there ever since. Hugo did not break them and no redirect I could have shipped at cutover would have found them, because they were not in Ghost’s sitemap either — the sweep is built from what Ghost still publishes, and these had already fallen out of it.
What changed is that I can see them now. My 404 page reports to analytics and Ghost’s did not, so five days of data surfaced years of stale index entries. That is an argument for instrumenting your own 404 page that has nothing to do with migrating.
They got redirects anyway. A reader arriving from a years-old search result is better served by the tag that now holds that content than by an apology:
| |
One route each, deliberately. A /tag/* wildcard is shorter and would take the site down a hole: SWA evaluates routes before it serves files, so that pattern matches /tag/homelab/ too and redirects every working tag page into a loop. The /author/* wildcard is safe only because that prefix has no real pages behind it.
While I was in there I added the three redirects I had explicitly refused at cutover, pointing the other way. /tag/entra-id/, /tag/microsoft-365/ and /tag/active-directory/ are what Hugo emits without the branch bundles. Nobody has them indexed, but they are what a reader guesses and what a careless internal link produces, and all three 404. They now 301 to the Ghost URLs the bundles serve. That is the opposite direction from the mistake in the tag section above, and it is worth being precise about which way round it goes.
What this actually cost me
Nothing, and that is the only reason I am comfortable writing it up. The 404 report is the safety net for everything the sweep cannot express, and it works — but only if the 404 page tells the truth. Had I left navigationFallback in the host config, all eight of these would have returned HTTP 200, none would have appeared in a 404 report, and the author archive would still be broken with no signal anywhere that it was.
A URL sweep proves exactly one thing: the URLs you fed it resolve. Feed it everything the old system publishes, then plan on reading the 404 report a week later for everything the old system stopped publishing but the index still remembers.
What it costs
| Item | Annual |
|---|---|
| Hugo | $0 |
| Azure Static Web Apps (Free) | $0 |
| GitHub (private repo plus Actions) | $0 |
| Azure DNS zone | about $6 to $12 |
| Search Console and Bing | $0 |
| Total | about $6 to $12 |
| Ghost(Pro) Creator | $300 |
Saving is roughly $290 a year. The costs not in that table are three to six hours of content review, a weekend of setup, and learning Hugo’s templating the first time I want to change something. One note on the zero-dollar claim: Azure DNS is billed per zone per month plus per million queries. Well under a dollar a month at my volume, but not zero.
What I would tell you to do differently
Every mistake I made traces back to a habit, and none of the habits are Hugo-specific.
Check the live system before writing the plan. My first draft fell apart on contact with my own site. Images were not served from my domain. My sitemap turned out to be an index of child sitemaps. Three of eleven tag slugs diverged. The author URL I planned to redirect appeared to 404 already, which was true of the slug I guessed and false of the real one. The plan said 20 posts and I had 40. Curling a handful of URLs took two minutes and invalidated a third of the document.
Build it before believing it. These were invisible to reading and obvious on the first real build: the taxonomy content path is not the URL path, overriding a partial replaces all of it (my breadcrumb fix would have dropped BlogPosting from all 43 posts), theme front matter contracts are not guessable, and Ghost’s card comments render as visible text.
A passing checklist is not a finished site. My URL sweep went 56 for 56 against a build whose navigation menu was an empty <ul>, with no tagline, no logo, and no homepage meta description. Every status code was right and the site was visibly wrong to anyone who looked at it. The cause generalizes: content migrates, configuration does not. Ghost’s settings table holds navigation, site description, logo, and social cards, and a post-walking converter never touches any of it.
URL-level verification proves nothing about presentation, and presentation is the first thing a reader sees.
The converter is ghost2hugo.py in my scripts repo. The runbook it came from stays private: it names the subscription and storage account holding the Ghost export, and that archive carries RSA private keys, session secrets and subscriber email addresses. Everything in it that is useful to you is in this post. If you are doing this migration and hit something I did not cover, find me on Bluesky at @techbyjeff.net.
