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.

1
2
3
4
5
6
7
8
9
[permalinks]
  [permalinks.page]
    posts = '/:slug/'        # post -> /{slug}/   matches Ghost exactly
  [permalinks.section]
    posts = '/archive/'      # move the section list OFF /posts/
  [permalinks.term]
    tags = '/tag/:slug/'     # Ghost-style singular /tag/{slug}/
  [permalinks.taxonomy]
    tags = '/tag/'

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 nameGhost 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:

1
2
3
4
5
# content/tags/entra-id/_index.md   <- directory is the normalised tag NAME
---
title: 'Entra ID'
slug: entra
---

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 PagesAzure SWA (Free)Cloudflare Pages
Real 301 redirectsNo, meta-refresh onlyYes, staticwebapp.config.jsonYes, _redirects
Custom cache headersNot supportedYesYes
Publish from a private repoNeeds a paid planYesYes
DNS stays at Azure DNSYesYes, native integrationApex impossible
Apex to www 301AutomaticYes, but not where you will look for itApex 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 members table 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.org and 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 posts40
Published pages3
Draft posts / pages5 / 2
Posts with non-empty html50 / 50
mobiledoc / lexical0 / 50
Distinct images referenced225 (about 24 MB)
Tags11 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.

1
2
3
4
5
winget install Hugo.Hugo.Extended      # 0.165.0 in winget
winget install Python.Python.3.13      # migration tooling only
winget install JohnMacFarlane.Pandoc   # migration tooling only
pip install beautifulsoup4 lxml pyyaml
hugo version                           # must print +extended

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:

1
2
3
4
# admin, one time, then reboot
New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' `
  -Name LongPathsEnabled -Value 1 -PropertyType DWORD -Force
git config --global core.longpaths true

Windows only. The Actions runner is Linux and never hits it.

Theme choice

1
2
3
4
hugo new site techbyjeff --format toml
cd techbyjeff && git init
git submodule add --depth=1 https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod
git submodule update --init --recursive

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:

1
WARN  Duplicate target paths: /about/index.html (2)

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:

1
2
3
4
hugo build --gc --minify --printPathWarnings 2>&1 | tee /tmp/build.log
if grep -q "Duplicate target paths" /tmp/build.log; then
  echo "URL COLLISION, fix before deploying"; exit 1
fi

Converting 40 posts

There is no maintained Ghost to Hugo converter. I checked before writing one.

ToolStateVerdict
jbarone/ghostToHugo (Go)v0.5.3, December 2020Dead. Parses mobiledoc, and Ghost 6 posts have mobiledoc: null.
zerohate/ghost-to-hugo (Node)4 commits, no releases, not on npmWeekend script pinned to a library last published in 2018.
Hugo’s own migration tools listChecked this monthGhost 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.

1
2
export JSON -> CardNormaliser (bs4) -> pandoc -f html -t gfm --wrap=none
            -> token restore -> URL rewrite -> YAML front matter

Running it:

1
2
3
4
5
6
7
8
python ghost2hugo.py "Ghost Exports\tech-by-jeff.ghost.2026-08-27-23-45-38.json" `
    --out C:\src\techbyjeff `
    --site-url https://www.techbyjeff.net `
    --permalink '/{slug}/' `
    --image-key cover `
    --redirects "Ghost Exports\redirects.yaml" `
    --aliases --tag-bundles `
    --images-from-zip "Ghost Exports\techbyjeff_archive.zip"

--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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
title: 'Knowing What Breaks Before You Turn Off RC4'
slug: knowing-what-breaks-before-you-turn-off-rc4   # exact, never regenerated
date: '2026-08-24T14:00:00+00:00'
draft: false
lastmod: '2026-08-25T18:45:00+00:00'    # only when > published_at
summary: ...                             # custom_excerpt
description: ...                         # posts_meta.meta_description
cover:
  image: /content/images/2026/08/rc4-card.png
  relative: false
  alt: ...
tags: [Active Directory, Security]       # posts_tags join, internal tags dropped
authors: [Jeffrey Stuhr]
canonicalURL: ...
ghost_id: aaaa1111                       # keep for re-runs and diffing

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:

1
2
3
4
5
for slug in $(ls content/posts | sed 's/\.md$//'); do
  curl -s "https://www.techbyjeff.net/$slug/" | pandoc -f html -t plain > /tmp/old.txt
  pandoc -f html -t plain "public/$slug/index.html" > /tmp/new.txt
  diff -q /tmp/old.txt /tmp/new.txt >/dev/null || echo "DIFFERS: $slug"
done

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:

1
2
grep -L 'og:image' public/*/index.html                       # no OG image at all
grep -l 'techbyjeff-site-card' public/*/index.html | wc -l   # posts on the fallback

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:

WhatFate
Microsoft Clarity tagKeep. Drop it into layouts/_partials/extend_head.html.
PrismJS from a CDNDelete. Chroma replaces it server side and you drop two CDN round trips from every page.
Eleven numbered sections of CSSPort 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:

1
2
3
4
hugo gen chromastyles --style=github      --mode=light --modeSelector \
  --classLight=light --classDark=dark --omitClassComments  > assets/css/syntax.css
hugo gen chromastyles --style=github-dark --mode=dark  --modeSelector \
  --classLight=light --classDark=dark --omitClassComments >> assets/css/syntax.css

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
  "trailingSlash": "auto",
  "routes": [
    { "route": "/rss/", "redirect": "/index.xml", "statusCode": 301 },
    { "route": "/sitemap-posts.xml", "redirect": "/sitemap.xml", "statusCode": 301 },
    { "route": "/sitemap-pages.xml", "redirect": "/sitemap.xml", "statusCode": 301 },
    { "route": "/sitemap-tags.xml",  "redirect": "/sitemap.xml", "statusCode": 301 },

    { "route": "/github/", "redirect": "https://github.com/<you>/<repo>", "statusCode": 301 },

    { "route": "/content/images/*",
      "headers": { "cache-control": "public, max-age=31536000, immutable" } },
    { "route": "/pagefind/*",
      "headers": { "cache-control": "public, max-age=86400" } }
  ],
  "responseOverrides": {
    "404": { "rewrite": "/404.html" }
  },
  "globalHeaders": {
    "cache-control": "public, max-age=600",
    "x-content-type-options": "nosniff",
    "referrer-policy": "strict-origin-when-cross-origin"
  },
  "mimeTypes": { ".xml": "application/xml" }
}

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:

1
/content/images/2024/11/icon-1.png  ->  301  ->  /content/images/.../icon-1.png/  ->  200

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:

1
2
Encountered an issue while validating staticwebapp.config.json:
A rule was already processed with a duplicate route /rss.

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
name: Build and deploy
on:
  push:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: deploy
  cancel-in-progress: false

jobs:
  build_and_deploy:
    runs-on: ubuntu-latest
    env:
      HUGO_VERSION: 0.165.0
      PAGEFIND_VERSION: 1.5.2
      TZ: America/Los_Angeles
    steps:
      - uses: actions/checkout@v7
        with:
          submodules: recursive
          fetch-depth: 0          # required for correct .Lastmod in sitemap

      - name: Install Hugo
        run: |
          curl -sfL --output-dir "${{ runner.temp }}" -O \
            "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.tar.gz"
          mkdir -p "${HOME}/.local/hugo"
          tar -C "${HOME}/.local/hugo" -xf "${{ runner.temp }}/hugo_extended_${HUGO_VERSION}_linux-amd64.tar.gz"
          echo "${HOME}/.local/hugo" >> "$GITHUB_PATH"

      - name: Build
        run: |
          hugo build --gc --minify --printPathWarnings \
            --baseURL "https://www.techbyjeff.net/" \
            --cacheDir "${{ runner.temp }}/.cache/hugo" 2>&1 | tee /tmp/build.log

      - name: Fail on URL collisions
        run: |
          if grep -q "Duplicate target paths" /tmp/build.log; then
            echo "::error::URL collision detected"
            grep "Duplicate target paths" /tmp/build.log
            exit 1
          fi

      - name: Build Pagefind index
        run: npx -y pagefind@${PAGEFIND_VERSION} --site public

      - name: Deploy to Azure Static Web Apps
        uses: Azure/static-web-apps-deploy@v1
        with:
          azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
          action: upload
          app_location: "public"
          skip_app_build: true

--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.

1
2
3
4
5
6
7
# PATCH sends ONLY the TTL. The record values are never in the request, so there
# is no path for a slip here to repoint mail or the site.
$body = '{"properties":{"TTL":300}}'
foreach ($r in @('A/@','CNAME/www')) {
  Invoke-RestMethod -Method PATCH -Body $body -Headers $h `
    -Uri "$zone/$r`?api-version=2018-05-01"
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
SWA=https://<your-app>.azurestaticapps.net

for s in posts pages tags authors; do
  curl -s "https://www.techbyjeff.net/sitemap-$s.xml"
done | grep -o '<loc>[^<]*' | sed 's#<loc>https://www.techbyjeff.net##' \
     | sort -u > /tmp/ghost-urls.txt

while read -r p; do
  code=$(curl -s -o /dev/null -w '%{http_code}' "$SWA$p")
  [ "$code" = "200" ] || echo "$code  $p"
done < /tmp/ghost-urls.txt

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:

1
2
curl -s -o /dev/null -w '%{num_redirects}\n' "$(curl -s https://www.techbyjeff.net/sitemap.xml \
  | grep -o '<loc>[^<]*' | head -1 | sed 's/<loc>//')"

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.

PathViews
/author/jstuhr/page/1/4
/tag/automation, /tag/bitwarden, /tag/itadmin, /tag/netapp, /tag/proxmox, /tag/synology, /tag/testing1 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/:

1
{ "route": "/author/*", "redirect": "/archive/", "statusCode": 301 }

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:

1
2
3
4
{ "route": "/tag/proxmox/",   "redirect": "/tag/homelab/",    "statusCode": 301 },
{ "route": "/tag/synology/",  "redirect": "/tag/homelab/",    "statusCode": 301 },
{ "route": "/tag/automation/","redirect": "/tag/powershell/", "statusCode": 301 },
{ "route": "/tag/itadmin/",   "redirect": "/tag/",            "statusCode": 301 }

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

ItemAnnual
Hugo$0
Azure Static Web Apps (Free)$0
GitHub (private repo plus Actions)$0
Azure DNS zoneabout $6 to $12
Search Console and Bing$0
Totalabout $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.