Why move off Ghost?

It has been a few years since I last updated the blog - I have changed jobs and the blog has got neglected. Previously this blog was hosted with Ghost (Pro) (formerly called Ghost.IO) - who have been great - but as I have decided to move it to a static site generated with Hugo and Cloudflare pages because it is much cheaper to run, and I am using it much less and so cannot justify the hosting fees that pay for the lovely always up Ghost CMS.

I have been really impressed with Hugo - this is the story of the migration - which took about 5 hours - in the hope it might help others making the same journey.

I am now Using VSCode with dev containers and FrontMatter to write this post, and using Git and Cloudflare Pages to publish and host it. It has been a smooth changeover from Ghost, with the added bonus that hosting is pretty much free, and I now own and control all the content.

Migration steps

I based my move on a couple of really helpful posts, https://blog.xmatthias.com/post/migrate-to-hugo/ and https://www.wrenhold.com/posts/hugo-dev-container-github-pages/ with a few tweaks and enhancements based on my experience.

Exporting data from Ghost

Grab the JSON data file. Ghost provide an export tool in the admin panel which dumps all the content into a large JSON data file - all posts and tags at least. This does not include the images.

For images there are two approaches - self serve and customer support. It is a shame Ghost do not have an online image export tool. This feels like a bit of lock-in friction and is my only gripe with them.

Initially I used ghost-image-downloader which worked well for all the post images but failed to download the images that are embedded within Ghost posts.

However, I discovered the easier method is to just email Ghost support, they will send you a zip file of your entire site upon request. It’s not as nice as a self serve option, and you have to wait till business hours, but is easier and more complete if you have any not linked to from a post.

Picking Hugo, Cloudflare and PaperMod

I chose Hugo because it had the biggest community, great docs, and I found some other people who had done the same migration, but you could certainly do something very similar with Jekyll or Gatsby.

I also chose Cloudflare Pages because it is free, I already use Cloudflare for DNS and SSL, making it a quick win for me. Cloudflare has good docs on deploying Hugo and a nice UI for setting up the site, but the static site could easily be hosted in S3 or Azure Blob Storage or any other static site host - it is just HTML files in the end.

For the Hugo theme there are loads to chose from (or build your own!). I went with PaperMod because it was simple and had some nice extras like tags, RSS and search which I did not want to lose by migrating from Ghost.

Setting up a dev environment

I really like using devcontainers with VSCode and docker because it means I get a new clean environment for each project and I can reset it with one command. VSCode comes with a devcontainer “Feature” for Hugo which makes it pretty easy to set up. Install the dev container extension into Code and then CTRL-P and select the New Dev Conatiner option and follow the wizard steps. You will end up with a new repository with a .devcontainer folder and a devcontainer.json file:

	"name": "Debian",
	"image": "mcr.microsoft.com/devcontainers/base:bullseye",
	"features": {
		"ghcr.io/devcontainers/features/git:1": {
			"ppa": true,
			"version": "latest"
		"ghcr.io/devcontainers/features/go:1": {
			"version": "latest"
		"ghcr.io/devcontainers/features/hugo:1": {
			"version": "latest"
			, "extended": true
	"customizations": {
		"vscode": {
			"extensions": [

You can see I have also installed the FrontMatter extension which is really useful for editing the post markdown files and a spell checker. This makes for a nice replacement for the back office/admin panel experience that came with Ghost, as with Hugo everything is just markdown files on disk and then git and CI for the publishing process.

Reload VSCode into the devcontainer and let it build/install. Will take a couple of minutes the first time but should be cached locally after that.

Set up a blank Hugo site

All admin of the Hugo site is done with the command line. The devcontainer comes with Hugo available on the Path, but I also checked a copy of the (nice and small single) binary into the repo for when I need to run it without a full container build.

I created a new Hugo site in a folder src below the root of the repo and then added the theme as a submodule:

hugo new site .

# add theme as submodule 
git submodule add --depth=1 https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod
git submodule update --init --recursive

The theme needs a config file, and PaperMod has lots of options. You can use the default TOML file, but I switched to the optional .yml version because it matched an example I was using. I converted the hugo.toml file to config.yaml with https://www.convertsimple.com/convert-toml-to-yaml/ but hand typing it is fine. Or copy the one below!

This was the src/config.yml I ended up with to enable site search, an RSS feed, code highlighting and the Tags pages, which reproduced everything I already had on Ghost:

baseURL: 'https://alastaircrabtree.org/'
languageCode: en-gb
title: Alastair Crabtree
theme: PaperMod
publishDir: public
      unsafe: true

enableRobotsTXT: true
buildDrafts: false
buildFuture: false
buildExpired: false

googleAnalytics: YOUR_GA_ID_HERE

  disableXML: true
  minifyOutput: true

    - HTML
    - RSS
    - JSON # necessary for search

  env: production # to enable google analytics, opengraph, twitter-cards and schema.
  title: ExampleSite
  description: "Alastair Crabtree's personal blog"
  keywords: [Developer, Blog]
  # author: Me
  # author: ["Me", "You"] # multiple authors
  # images: ["<link or path of image for opengraph, twitter-cards>"]
  DateFormat: "January 2, 2006"
  defaultTheme: auto # dark, light
  disableThemeToggle: false

  ShowReadingTime: true
  ShowShareButtons: false
  ShowPostNavLinks: true
  ShowBreadCrumbs: false
  ShowCodeCopyButtons: true
  ShowWordCount: true
  ShowRssButtonInSectionTermList: true
  UseHugoToc: true
  disableSpecial1stPost: false
  disableScrollToTop: false
  comments: false
  hidemeta: false
  hideSummary: false
  showtoc: false
  tocopen: false

    text: "Alastair's Blog"
    iconHeight: 35

  # profile-mode
    enabled: false # needs to be explicitly set

  # home-info mode
    Title: "Ta Da! \U0001F44B"
    Content: Something snappy to say here!

    - name: x
      url: "https://twitter.com/alastairtree"
    - name: stackoverflow
      url: "https://stackoverflow.com/users/3140853/alastairtree"
    - name: github
      url: "https://github.com/alastairtree"

    hidden: false # hide everywhere but not in structured data
    hiddenInList: true # hide on list pages and home
    hiddenInSingle: false # hide on single page

  # Search config - see https://fusejs.io/api/options.html
    isCaseSensitive: false
    shouldSort: true
    location: 0
    distance: 1000
    threshold: 0.4
    minMatchCharLength: 0
    limit: 10 # refer: https://www.fusejs.io/api/methods.html#search
    keys: ["title", "permalink", "summary", "content"]

    - identifier: search
      name: Search
      url: /search/
      weight: 20
    - identifier: Tags
      name: Tags
      url: /tags/
      weight: 20
    - identifier: archives
      name: Archive
      url: /archives/
      weight: 25
    - identifier: About
      name: About
      url: about/
      weight: 30

pygmentsUseClasses: true

    noClasses: false
    anchorLineNos: true
    codeFences: true
    guessSyntax: true

The first post

I created a test post to see how it all looked:

hugo new posts/hello-world.md`

# Run the dev server to check out the results
hugo server --buildDrafts

Looking good for zero lines of code so far.

Setting up for dev in VSCode

I set up a .gitignore file based on https://github.com/github/gitignore/blob/main/community/Golang/Hugo.gitignore

### Hugo ###
# Generated files by hugo

# Executable may be added to repository

# Temporary lock file while building

I like CTRL-B in VSCode to run the server, so I added task.json file to the .vscode folder, and another task to do the prod build.

Hugo has the idea of environments built into the config, and you can choose which one based on CLI settings which makes hosting a test site really easy as you can see from the prod build task:

In .vscode/tasks.json:

    "version": "2.0.0",
    "tasks": [
            "label": "Hugo dev server",
            "type": "shell",
            "command": "hugo",
            "args": ["server", "--buildDrafts", "--buildFuture"],
            "group": {
                "kind": "build",
                "isDefault": true
            "options": {
                "cwd": "${workspaceFolder}/src"
            "label": "Hugo build prod",
            "type": "shell",
            "command": "hugo",
            "args": ["--baseURL", "https://alastaircrabtree.com"],
            "options": {
                "cwd": "${workspaceFolder}/src"

I set the spell check language for the Latex extension in settings.json

    "ltex.language": "en-GB"

And I created config for the FrontMatter VSCode extension in frontmatter.json in the repository root. This adds a nice Admin UI for posts and a media browser experience inside VSCode, and even works in the web browser when you use GitHub Codespaces.

  "$schema": "https://frontmatter.codes/frontmatter.schema.json",
  "frontMatter.preview.host": "http://localhost:1313",
  "frontMatter.site.baseURL": "https://alastaircrabtree.com",
  "frontMatter.framework.id": "hugo",
  "frontMatter.framework.startCommand": "cd src && hugo server --buildDrafts --buildFuture",
  "frontMatter.content.pageFolders": [
      "title": "posts",
      "path": "[[workspace]]/src/content/posts",
      "previewPath": "posts",
      "contentTypes": [
  "frontMatter.content.publicFolder": {
    "path": "src/static",
    "relative": true
  "frontMatter.dashboard.openOnStart": true,
  "frontMatter.content.defaultSorting": "PublishedDesc",
  "frontMatter.templates.enabled": true,
  "frontMatter.templates.folder": "src/archetypes"

FrontMatter has the idea of Content Types which is schema to enable some post page UI, and having multiple types of content pages. I have not initialised the Content Type settings in FrontMatter yet - those can be generated in the UI using the post sidebar “create content-type” button if you want from the sample post included in the theme.

This is a shot of the UI in FrontMatter which is pretty nice, and there is even an image picker which I used to paste this image into the post:

I also added a template post to the Hugo archetypes folder to get the theme defaults set up. This means the template post generated by the Hugo CLI matches the theme. This is the src/archetypes/posts.md file I used:

title: "My 1st post"
date: 2020-09-15T11:30:03+00:00
slug: "my-first-page"
summary: "homepage snippet"
description: null # appears in post page before header imagr
tags: ["first"]
showToc: false
TocOpen: false
draft: true
hidemeta: false
comments: false
disableShare: false
disableHLJS: false
hideSummary: false
searchHidden: false
ShowReadingTime: true
ShowBreadCrumbs: false
ShowPostNavLinks: true
ShowWordCount: true
ShowRssButtonInSectionTermList: true
UseHugoToc: false
    image: "<image path/url>" # image path/url
    alt: "<alt text>" # alt text
    caption: "<text>" # display caption under cover
    relative: false # when using page bundles set this to true
    hidden: false # only hide on current single page
some content here

Migrate data into Hugo with GhostToHugo

Now a site is up and running, I had to migrate my data into it. Using the handy tool GhostToHugo got me 90% of the way there by generating markdown files from the JSON data file:

curl https://github.com/jbarone/ghostToHugo/releases/download/v0.5.3/ghostToHugo_0.5.3_Linux_x86_64.tar.gz | tar -xzf -
chmod +x ghostToHugo
./ghostToHugo --dateformat "2006-01-02T15:04:05" -v  GhostJsonDumpFile.json

I had to move the markdown files that were output into the existing Hugo site. The only real problem area was the images - post images migrated with the format __GHOST_URL__/content/images/yyyy... so I had to fix those to point to the Hugo image folder in static/images/yyyy... where I had placed the media folder I got from Ghost support. A bit of find and replace did the trick on the URL.

The Markdown tag for the main post image needed to be [cover] image in the default TOML format. Seems like GhostToHugo was expecting a different image header name than the theme I used. Each post needed a small amount of manual tweaking to get the images working, but it was pretty quick. Once gotcha was the [cover] section seemed to need to be last in the markdown header. Also watch out for the images embedded in the posts - they need to be re-pathed as well.

In the end all the headers for each post looked something like the below:

title = "Absolute cache expiry corrupts absolutely?"
categories = []
date = 2019-07-12T14:29:47Z
description = ""
draft = false
slug = "absolute-cache-expiry-corrupts-absolutely"
summary = "Should you specify absolute expiry of a cache item from the current time or from the current time in the UTC time zone? The answer may not be what you expect."
tags = ["LazyCache", "c-sharp", "DateTime", "MemoryCache"]

    image = "/images/2019/07/5998854899_79d92790c7_b.jpg"
    alt = "a kitchen clock"
    caption = "There are 2 hard problems in comp sci..." # display caption under cover
    relative = false # when using page bundles set this to true
    hidden = false # only hide on current single page

Search and archive features

Alongside the static pages I copied into the content folder, I dropped in a couple of almost empty pages for search and archives which turns on those features in the theme. I also added them to the nav in the config file which you can see above.

title: "Search"
layout: "search" # necessary for search
summary: "search"
placeholder: "Type your search terms here..."
title: "Archive"
layout: "archives"
url: "/archives/"
summary: archives

Deploying to Cloudflare Pages

To deploy to the web I pushed to my git repository, logged into Cloudflare, and followed the steps for running Hugo on Cloudflare Pages at https://developers.cloudflare.com/pages/framework-guides/deploy-a-hugo-site/. As Cloudflare was already managing DNS and certificates this was pretty easy.

I “linked” Cloudflare with the GitHub repo and used command hugo --baseURL $CF_PAGES_URL in the /src folder to generate the site. Cloudflare watches the git repo and will trigger a publish job automatically. I configured it to publish the default public folder in src. It was then published to the web to a staging site.

I added a beta subdomain to my DNS to try out the new site before moving the root domain, and configured pages to host on the subdomain. This made it easy to test before migrating the root domain. I needed to tweak the --baseURL for the beta site to get everything working correctly.


The URLs for the pages changed slightly with the new site as they moved into the posts folder. I needed to maintain the old links once I moved the DNS, so I used a redirects file in the static folder to do this.

I generated a file by doing a ls > _redirects on the post directory. The file names by default were the slugs used in the Ghost URLs. I placed the _redirects file in the src/static directory which is used by Cloudflare to redirect the old URLs to the new ones and used a bit of copy-paste with multiple cursors in VSCode to create the correct format for the redirect rules.

Each line in the file is a redirect from the old URL to the new one. I started with 302 temporary redirects to avoid the browser caching them:

/absolute-cache-expiry-corrupts-absolutely/ /posts/absolute-cache-expiry-corrupts-absolutely/

I tested the redirects by visiting the old URLs on the staging site and checking they went to the new ones. Once happy I appended 301 to the end of each line to make it a permanent redirect.


One final thing was to add a nice Favicon. I am very average at design but thankfully https://favicon.io/favicon-generator/ does the hard work for you. Put the generated favicon files in the static folder and add the following to the config.yaml file:

    favicon: "/favicon.ico"
    favicon16x16: "/favicon-16x16.png"
    favicon32x32: "/favicon-32x32.png"
    apple_touch_icon: "/apple-touch-icon.png"
    safari_pinned_tab: "/apple-touch-icon.png"

And that was it! The site was live and running on Cloudflare Pages. Once everything was happy I configured Cloudflare to publish the site to the root domain and then switched the DNS over from Ghost to the Pages site. The migration was complete. Hopefully this helps others that want to make the same journey.