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 worth the name. 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.

What I actually use Ghost for is this: 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.

The part I care most about, and the part most migration guides get wrong, is that not one post URL changed. No redirects for posts, no ranking risk on the thing that actually drives my traffic. This is the walkthrough, including the four or five places where my first plan was confidently wrong.

The headline: 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/'

Two of those lines are load bearing in ways that are not obvious.

permalinks.section is not optional. Leave it 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/ both kills that and gives me back the archive page Ghost had.

permalinks.term changes the URL only, not the front matter key. 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.

That was the whole migration surface. Everything after this is mechanics.

The tag slug trap

Here is the first thing that bit me, and it is specific enough that I would have missed it entirely if I had not checked.

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.

There are two fixes. The cheap one is three 301 redirects at the host. The exact one 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 two things 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, which is exactly 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, and it is worth knowing. 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, which is exactly what Ghost was doing. The generated hostname redirecting too is the giveaway that this is domain-level behavior rather than anything your config file can express.

One wrinkle the portal does not warn you about: 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 cost of getting this wrong is not obvious either, because nothing breaks. The site works whichever hostname is default. But 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}' before you decide you are done.

Two Free tier limits worth knowing: 100 GB per month of bandwidth as a hard cap with no overage option, and 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 turned out to be 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, not 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.

That last one is worth the 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.

Here is what my export actually contained, parsed rather than assumed:

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, which I will get to.

What I dropped, and what is genuinely lost

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, not from Ghost’s Network tab. The newsletter had three real subscribers and they signed up to know when I publish, which RSS does.

Actually lost: 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. And automatic image resizing goes away in exchange for keeping image URLs stable, a tradeoff I will defend in a minute.

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, not part of 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.

The Windows path length landmine

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 rather than a path length one.

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 actual requirements, not after reading theme galleries.

It does not fight root-level permalinks, which was the highest-risk requirement and is now verified rather than assumed. 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, from the git history rather than the badge: 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.

The URL collision trap, and the four lines that guard it

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

Four lines, and they protect the one thing I could not afford to get wrong.

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.

So I wrote ghost2hugo.py. The design point that makes it work is that 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 actually serve, so it correctly emits no self-aliases. That flag is what makes the zero-redirect claim true rather than 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

What was actually in my content

I built a card mapping table covering every Koenig card type, with honest notes about which ones need manual cleanup. Then I scanned all 50 posts and found 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 my careful mapping table marked “needs manual cleanup” was moot, and the script’s riskiest code paths never executed. Scan your own content before budgeting time for cleanup you may not have to do.

Three converter bugs worth naming

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.

The verification that actually catches content loss

The highest-value check in the migration, and about fifteen minutes for 40 posts. Diff the rendered text, not the markdown:

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 markdown will not show you silent content loss. This will.

The 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 is not cosmetic, it is a directly measurable cost: those posts render as a bare text link instead of 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. That is the difference between distinct branded cards and 43 identical generic ones, and nothing warns you about it.

Verify after building. Two lines catch the whole class of error:

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: keep the URLs, and be honest about why

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, exactly the thing that replaces 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. When I checked the live site, that argument turned out to be much weaker than I 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 rather than the file. So 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.

Two traps sink the naive extraction approach. First, 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. Second, 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 not in 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 was 15 KB containing three things with three different fates:

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, not script.

Three traps here, all from the same mistake: reading rendered output instead of the two files 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 genuinely 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. That is not an inconsistency to tidy up, it is the site’s actual appearance.

Read the override, not 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, not a copy. 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 rather than re-derived:

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.

The host config, and four corrections

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" }
}

Four things I got wrong in the first version, all of which would have cost me something.

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/ rather than the .png that is actually indexed. On a migration whose entire thesis is URL preservation, that is the wrong default. auto gives folders a trailing slash and leaves files alone.

That one also produced a red herring worth remembering: 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, not a warning: the whole 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 rather than derived, which guarantees canonical www URLs during cutover. Pagefind must run after hugo build and before deploy so public/pagefind/ actually ships. And fetch-depth: 0 matters for SEO, because without it Hugo’s .Lastmod from git is wrong and pollutes the sitemap. One more: skip_api_build is not a valid input to that action despite appearing in plenty of examples, and a real run warns Unexpected input(s).

The portal will wire things up behind your back

If you create the Static Web App in the Azure portal rather than the CLI, it connects your GitHub repo whether you asked for it or not. It committed a second workflow directly to main, so every push ran two deploy pipelines. It named the deployment secret after the app rather than the generic name every example uses, so my own workflow failed with deployment_token was not provided. And it configured its workflow 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 rather than minting a second copy, because GitHub secrets are write-only and two live deployment tokens for one app is a needless credential to rotate later. 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.

DNS, and the two records that matter

The custom domain flow in the portal is worth using here, because the apex needs an Azure DNS alias record pointing at the SWA resource: not an A record, and not a CNAME, which is illegal at a zone apex. 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"
}

PATCH rather than PUT 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 rather than eyeballing: 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 does not cause a slow SEO problem, it silently stops mail delivery.

Search, RSS, and three subscribers

Search is Pagefind, already wired into the workflow. Two things to do 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 static hosts resolve a directory request to index.html, not index.xml. 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 they are the only things that can show actual 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, not Ghost’s, so unlike the built-in numbers, that history survives the migration.

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; 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: it contains four entries pointing at the child sitemaps, not your post URLs. An earlier version of my runbook curled it directly, which passed while testing nothing at all.

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. The check is one line, and it 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>//')"

Two things not to do. 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.

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. And one honest 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

Three habits account for basically every mistake I made, and none of them are Hugo-specific.

Check the live system before writing the plan. Six claims in my first draft were wrong on contact with my own site. Images were not served from my domain. My sitemap was an index, not a list of posts. Three of eleven tag slugs diverged. The author URL I planned to redirect already 404ed. I had 40 posts, not 20. Curling a handful of URLs took two minutes and invalidated a third of the document.

Build it before believing it. Four defects 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 silently 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.

Load the new homepage next to the old one before you call a migration done, then go look at the classes of failure your checks cannot see.


The companion converter script and the full runbook live in my PowerShell scripts repo. If you are doing this migration and hit something I did not cover, find me on Bluesky at @techbyjeff.net.