If you are reading this, you are looking at a brand-new montemagno.com. It looks a lot like the old one on purpose, but underneath it is a completely different animal: no database, no server, no admin panel. Just Markdown files in a private GitHub repo, built by Hugo, verified by GitHub Actions, and served from GitHub Pages.
I have been blogging here since 2012 (hi, Tumblr) and on Ghost since 2017. Ghost was great, but over the years my wishlist kept growing: I wanted to write in my editor, keep images next to my posts in git, have dark mode, get rid of jQuery and ad scripts, and most of all own the whole thing as plain files. The thing that always stopped me was the sheer size of the job: 274 posts, a thousand images, nine years of URLs that people and search engines link to, and a theme I really liked.
So this time I did it differently. I opened a terminal, started GitHub Copilot CLI, and said roughly this:
I want to completely remake my site to be a static site with full backwards compat with the format/slugs, images, import of data, easily writing things in markdown and then having it displayed, easy image management. Here is my Ghost export and my existing design system. Do deep research on montemagno.com and let's plan it all out.
About an afternoon later the initial version of the site you are looking at existed, every one of 2,172 legacy URLs was verified to resolve, and a CI pipeline was standing guard. This post is the story of that migration, the follow-up work to get it launch-ready, and what I learned about working with an agent on a project this size.

Step 0: Plan first, code second
The best decision I made was starting in plan mode. Instead of diving into code, Copilot CLI spent the first chunk of time doing research:
- It parsed the Ghost export JSON and reported exactly what was in it: 298 posts (274 published, 13 drafts), 10 pages, 323 tags, ~1,330 image references, which posts were stored as Ghost "mobiledoc" Markdown cards versus the newer Lexical editor, how many used gists, YouTube, Fireside podcast players, and so on.
- It spun up a research sub-agent that crawled the live site and came back with a precise inventory: URL patterns (
/slug/,/tag/slug/,/page/N/,/rss/,/tag/slug/rss/,/author/james-montemagno/), posts per page, every<meta>and JSON-LD tag Ghost emits, the fonts and colors in the Cedar theme, the third-party scripts, and even the fact that/slug/amp/301s back to the canonical URL. - It dug through my old
montemagno.com-assetsrepo and found the Cedar theme source, the brand guidelines, and aredirects.jsonwith 293 legacy redirects going back to the Tumblr days.

Then it asked me a handful of structured questions: which static site generator, where to host, how faithful to keep the design, what to do about comments, newsletters, ads, and the little dog that pops up at the bottom of the page. I pushed back on its first suggestion (Astro), it gave me an honest comparison of Hugo, Eleventy, Statiq, Zola, and Jekyll, and we landed on Hugo on GitHub Pages with an "evolve the Cedar look" design direction.
The output of plan mode was a 16-item plan with dependencies. Only after I approved it did anything get written to disk. That alone saved me from a dozen wrong turns.
The architecture
Here is what we ended up with. Every box is files in one repository.

A few things worth calling out:
- Images keep their exact URLs. Ghost served images from
/content/images/YYYY/MM/name.png. The new site puts them atstatic/content/images/YYYY/MM/name.png, so every hotlink, Google Images result, and old tweet still works. New posts just drop images into the current month's folder. - Redirects are front matter. GitHub Pages has no server-side redirects, so each legacy URL becomes an
aliases:entry on the post it should point to. Hugo emits a tiny HTML stub with<meta http-equiv="refresh">and a canonical link. Search engines treat it as permanent. - Tags are real pages.
content/tags/<slug>/_index.mdholds the display name (soxamarin-formsrenders as "Xamarin.Forms"), and a few near-duplicate tags (vs-code,visual-studio-code) were merged intovscodewith aliases for the old URLs and their RSS feeds. - RSS at
/rss/is still XML. Feed readers have been hittingmontemagno.com/rss/for years. Hugo now writes the feed to/rss/index.html(what GitHub Pages serves for that directory) with a Ghost-compatible structure: fullcontent:encoded,media:contentfeature images, categories, and the original Ghost post IDs as GUIDs so nobody's reader shows 274 "new" items. - No build-time API calls. The Apps page (more on that below) reads
data/github.json, which a scheduled Action refreshes daily. Builds are deterministic and fast.
The migration pipeline

Convert: HTML to Markdown, for real
Ghost's export includes rendered HTML for every post, which is what we converted (the mobiledoc/lexical JSON is an implementation detail). The converter uses Turndown with a set of custom rules that Copilot wrote after sampling the awkward cases in my content:
- Fenced code blocks with the language preserved (and a few typos like
language-csharonormalized). - Heading IDs preserved as
{#id}attributes so in-page anchors keep working. - Ghost image cards with captions become
{{< figure >}}shortcodes; plain images become plain Markdown. <script src="https://gist.github.com/...">becomes{{< gist user id >}}(118 of them!), YouTube iframes become{{< youtube id >}}, Fireside players become{{< fireside url >}}.- Every ad block (AdSense, Amazon affiliate widgets, DeveloperMedia) and tracking script is stripped.
__GHOST_URL__placeholders andstorage.ghost.ioCDN URLs are rewritten to root-relative paths.
It also found and fixed things I had never noticed: a page with a literal </3> closing tag, posts where Markdown image syntax had been saved as plain text, and four image URLs that differed from a sibling only by case (github.png vs github.PNG), which is fine on Linux and a collision on Windows. That last one only surfaced when the first CI run on Ubuntu failed the URL check, and the fix was a second static-case/ mount folder.
Verify: 2,172 URLs, every build
This is the part I am most happy about. A script builds a manifest of every URL the old site served: all post slugs, tag pages and their RSS feeds, pages, images, the 250 usable redirect sources, plus whatever the live sitemap-*.xml files listed. A second script checks that every one of them exists in the Hugo output, case-sensitively, and fails the build if not.

It caught real bugs: the case-collision above, a redirect that pointed at a typo'd /serach/, and a pair of duplicate rules in the original redirects.json that pointed the same URL at two different places.
The design: Cedar, evolved
I told Copilot "evolve the Cedar look" and that is what it did. The full-viewport cover, the big condensed headlines, the sticky header that hides on scroll, the slide-in drawer, the alternating post list: all there. But it is rebuilt with modern CSS custom properties, no jQuery, no Font Awesome (inline SVG icons instead), system-aware dark mode with a toggle, a copy button on code blocks, responsive embeds, and prev/next and related-post cards at the bottom of every article. Fonts still come from my Adobe Fonts kit, but via a single CSS link instead of a JavaScript loader.

Search moved from GhostHunter (jQuery plus a Ghost API key) to Pagefind, which indexes the built HTML after Hugo runs and searches entirely in the browser. And yes, Milli still pops up from the bottom of the page when you find her.
The Apps page, powered by GitHub
Once the migration was done I asked for something fun: "For Apps, do you think we could do something really cool with my GitHub profile?"
The result is the new Apps page. A curated list of apps lives in data/apps.yaml (name, tagline, store links, platforms, and an optional repo). Everything else comes from GitHub: a daily Action runs a small Node script that pulls my profile, every repository, pinned items, and contribution stats (REST plus GraphQL, using the workflow's own token), and commits data/github.json only when something changed. Hugo then renders:
- Featured app cards enriched with live stars, last-commit date, and language.
- A "by the numbers" strip: 12,000+ stars, 291 original repos, followers, contributions in the past year, years on GitHub.
- Language bars and fun facts (most starred, most forked, first-ever repo from April 2012, how many repos I created this year).
- Pinned repos with their social-preview images, the most-starred open source, and "what I'm hacking on right now."
No client-side API calls, no rate limits, and it stays fresh without me touching it.
Fit and finish: from migrated to launch-ready
At that point I had the content, the design, and even a shiny new Apps page. But getting the migration working in an afternoon was not quite the same as being ready to send everyone to it. In a follow-up session, I asked Copilot: "What still needs to happen before I actually publish this?"
That question turned into 19 GitHub issues, which was a lot more useful than a vague list of things to remember. We split independent fixes across Copilot CLI sub-sessions in separate git worktrees: accessibility, feeds, search, content cleanup, and build checks. Each session had its own branch, and the work came back as focused pull requests that we reviewed and merged once their checks passed. More work could happen in parallel, but I still needed to review it and make the decisions.
The interesting bugs were not missing pages. The homepage needed its paginator initialized before Hugo rendered the head metadata. Posts made almost entirely of embeds needed their titles included in search, and related-post recommendations needed the taxonomy index wired up correctly. Even the code-copy button had a surprise: copying rendered text could double the newlines. None of those would have stopped the migration, but they are exactly the things you notice once you start using a site.
We also spent time on the things that make it easier for everyone to use. Text and button contrast improved without losing the orange branding, keyboard focus stayed inside the mobile drawer and image lightbox, and long headings and the Apps profile stopped overflowing narrow screens. The image pass added or improved 130 alt texts and supplied dimensions for 33 raw image tags. That was a targeted pass over recent and important content, not a claim that thirteen years of images had suddenly been fully audited.
The feeds were a good reminder that "the URL works" is only the beginning. An old tag-feed URL returning an HTML redirect is not much help to a feed reader expecting XML. We repaired all eight merged legacy tag feeds and checked their contents, not just whether the files existed. GitHub Pages still serves those directory-style feeds with a text/html content type, so we documented an optional Cloudflare response-header rule. A successful local build cannot fix the hosting platform's response headers.
As we fixed things, we taught CI to catch them next time: redirect destinations, metadata and headings, sitemap consistency, preserved static files, and search assets all joined the legacy URL checks. A page marked noindex should not quietly slip into the sitemap, and an old image URL should still serve the original file. We added 30 offline tests for the checker rules themselves, too. If I am relying on an agent to build my safety net, I want evidence that the net actually catches something.
Not everything belonged in that pipeline. External sites started blocking link checks, so we narrowed that audit to important pages and recent posts, throttled requests, and treated blocked responses as unverified rather than broken. We refreshed evergreen copy and repaired confirmed dead links, but kept the broad external crawl out of CI. Image optimization, older-image alt text, and browser tests in CI stayed on the follow-up list. There is a difference between ready to launch and nothing left to improve.
Then we rehearsed deployment on staging.montemagno.com without touching production. The hosted check passed all 2,172 legacy URLs, confirmed a 274-page search index, and checked HTTPS, redirects, real 404s, robots.txt, and the sitemap. The feed content-type warning stayed visible, and production DNS cutover remained a separate step for me to handle. With that rehearsal behind us, the site is now live at montemagno.com.
That second pass is what gave me the confidence to ship it. The first afternoon showed that we could build the site; the follow-up turned the rough edges into small, reviewed fixes and repeatable checks.
What it's like to work this way
Between the initial migration and that launch pass, a few things stood out about working with an agent in the terminal:
Plan mode is the killer feature. Research first, questions second, code third. The plan had dependencies, the agent tracked its own todo list, and when I changed my mind ("don't publish yet, I unpublished it") it kept deploys on a manual trigger while we were getting ready to launch.
It verifies its own work. It took Playwright screenshots of the home page, a code-heavy post, the tag page, the mobile drawer, and dark mode, looked at them, and fixed what was wrong (a squished avatar, a blurry upscaled dog, trailing whitespace from a render hook). It parsed the RSS output as XML to prove it was valid. It ran the URL verifier after every change.
It hits real-world walls and climbs them. Windows path restrictions on alias names, Hugo's new template layout system, the HTML minifier corrupting the XML feed served from an .html file, GitHub Pages being case-sensitive when my laptop is not. Each one showed up as a failure, got diagnosed, and got fixed with a small, explained change.
You still steer. I made every product decision: Hugo over Astro, no comments, RSS instead of a newsletter, keep the dog, "Follow" instead of "Newsletter & Feed" in the nav, weight the tag cloud toward what I write about now. The agent did the research that made those decisions easy and then executed them.
Writing a post now
After all that migration and launch work, this is the part I actually wanted: writing the next post without thinking about any of it.
hugo new posts/my-next-post.md
Fill in the front matter, write Markdown, drop images into static/content/images/2026/10/, and set draft: false when the post is ready. Push and open a pull request. Once the post is merged to main, CI builds the site, rebuilds the search index, runs the compatibility and launch checks, and deploys to montemagno.com. The Hugo build itself takes about six seconds.
---
title: "My Next Post"
slug: my-next-post
date: 2026-10-16T09:00:00-07:00
draft: false
description: "One or two sentences for search and social."
image: /content/images/2026/10/cover.png
tags: [github-copilot, dotnet]
---
If you have been putting off a migration like this because of the sheer volume of stuff that could break, I would encourage you to try the plan-first approach. Hand Copilot the export, point it at the live site, tell it what must not change, and let it build the safety net before it builds anything else. Then make time for that second question: what still needs to happen before I publish?
Explore GitHub Copilot
I used GitHub Copilot CLI for this project because I wanted to work in the terminal, but that is only one way to get started. You can explore GitHub Copilot in your editor, in the CLI, in the Copilot app, and on GitHub itself. Pick the place you are most comfortable working and give it a real task you have been putting off. It does not have to be thirteen years of blog posts!
If you are new to it, check out my First Steps with GitHub Copilot guide. And if you would rather follow along with a video, here is my new one:
Huge thanks to the Ghost team for nine great years. Here's to the next thirteen as plain markdown files.