All posts

5 min read Nathan Broadbent

Migrating the DocSpring Blog from Hugo to Ghost

How we moved our blog from Hugo to Ghost in 2020, and why we chose to host it on DigitalOcean.

The Hugo logo points toward the Ghost logo to illustrate the blog migration.
Historical note: This is our April 2020 migration story. The prices, plans, screenshots, and setup instructions describe that time and should not be used as current hosting quotes.

We've just finished migrating the DocSpring blog from Hugo to Ghost.

Hugo has been great, but writing a new blog post involves editing markdown in VS Code, manually copying and resizing images, committing code to a git repo, etc.

Ghost is an open source CMS, and it has a beautiful post editor that provides a much better writing experience.

I was originally planning to use Ghost(Pro) to host our new blog. We had built a custom Ghost theme and imported all of our posts, and it was almost ready to launch. The last thing to work on was setting up a proxy and hosting the blog at https://docspring.com/blog/. We already had this working with Hugo, and I wanted to preserve all of the links that were indexed by Google. (Using a /blog/ subdirectory on your root domain can also be better for SEO, although this is up for debate.)

Unfortunately, it turns out that hosting a Ghost(Pro) blog on a subdirectory is a premium feature, and it's only available on the Business plan ($199), plus an additional $50/mo. The total cost would be $249/mo.

This would be too expensive, and I had already invested a lot of time into setting up Ghost, so I started looking at some other hosting options.

The first thing I did was to export all of the blog posts and images from the Ghost(Pro) blog.

Ghost can export all of your blog posts in a JSON file, but it doesn't have a way to export all of your images. I used wget2 to scrape the blog website and download all of the images.

cd /tmp
mkdir ghost_blog
cd ghost_blog
wget2 --recursive -A jpg,jpeg,png "https://docspring.ghost.io/blog/" 
Note: wget version 1.x can't download any images from "srcset" attributes, which are used by Ghost. (See this mailing list post for wget.) wget2 is a new version of wget which has support for "srcset" attributes. It's also a lot faster. I installed wget2 from source by following these instructions.

I then tried to set up Ghost on Heroku, using this ghost-on-heroku repo on GitHub.

One problem I ran into was that images need to be hosted on Cloudinary or AWS S3, because files written to a Heroku dyno’s ephemeral filesystem do not persist across restarts. I also found out the ghost-storage-cloudinary adapter doesn't have any support for serving local images from /content/images/. (You can either use Cloudinary or the local image files, but not both.) I didn't want to upload all of my existing images to Cloudinary and then manually update all of the blog posts, so I figured out how to support this case and opened a Pull Request on ghost-storage-cloudinary.

The next problem was that Ghost was running extremely slowly on Heroku. I upgraded the JawsDB MySQL database to use their $10/mo plan, and this helped to improve the speed. However, the blog started to slow down again, and eventually each blog post was taking 12-15 seconds to load. This continued happening even after I tried using 1x and 2x Standard dynos on Heroku. I never figured out why this was happening, and I probably broke something with my custom changes and configuration. I couldn't see any errors in the logs and I didn't want to spend any more time looking into it, so I decided to try setting up Ghost on DigitalOcean.

DigitalOcean provides a 1-Click App installation for Ghost. I had never used DigitalOcean before, and I was very impressed with this experience.

Ghost Hosting | DigitalOcean Marketplace 1-Click App
Ghost is a fully open source, adaptable platform for building and running a modern online publication. It powers blogs, magazines and journalists from Zappos to Sky News.

When you click "Create Ghost Droplet", you are taken to a pricing page that looks like this:

Make sure that you click the little left arrow to show all of the cheaper prices:

Ghost runs perfectly fine on a $5/mo DigitalOcean droplet, and that's what we're using to run this blog. Regular server backups cost an additional $1, for a grand total of $6/mo.

After setting up Ghost for the first time, the blog will contain a few demo posts. It's annoying to delete these one at a time, so you can go to "Labs", and then click the "Delete" button at the right of "Delete all content".

After deleting all of the default posts, I used scp to upload all of the images to the /content/images directory on the server:

scp -r /tmp/ghost_blog/docspring.ghost.io/content/images/* root@<blog ip>:/var/www/ghost/content/images/
ssh root@<blog ip>
chown -R ghost:ghost /var/www/ghost/content/images
Make sure the /content/images directory is owned by the "ghost" user and group instead of root, otherwise Ghost won't be able to upload any new images.

I imported the JSON file that contained all of my previous blog posts. None of our previous blog posts had any "featured images", so I used the built-in Unsplash integration to choose some images.

This feature made it really easy to set up featured images for all of our existing blog posts. If you want to design a custom post image, I highly recommend Figma:

We also use Cloudflare to cache public blog pages and assets. Cache hits reduce the work our origin server has to do. Cache misses and uncached requests still reach the server, so caching alone is not a guarantee against overload.


Summary

  • Ghost(Pro) is an excellent option if you don't want to manage your own servers. You should consider this option if you want to host your blog on a subdomain (e.g. blog.yourdomain.com)
  • $249/mo is too expensive if you need to run Ghost(Pro) as a subdirectory on your root domain.
  • It can be difficult to get Ghost running on Heroku, and I ran into many more issues than I expected.
  • DigitalOcean is the best option for running Ghost on your own server, and it starts at only $5 per month ($6 including regular server backups.) This option gives you full control over your Ghost installation.
  • Whatever hosting option you choose, you should consider putting Cloudflare in front of your blog.

Thanks for reading, and I hope you found this post helpful!

(P.S. This is the first blog post that I wrote using Ghost's editor, and it's been a great experience!)

More posts

  1. A cut ruby gemstone fitted exactly inside a navy blueprint outline, connected to four code cards with teal check marks, above a tag reading typed: strict.

    14 min read

    Going all-in on Sorbet

    Sorbet sat in our Rails app for 18 months with a tenth of the code typed. Then AI agents typed the rest in four days, and we kept going.

  2. An SDK card and an API card, each connected by a line to a filled PDF form

    4 min read

    PDF form filler SDK vs API

    When a PDF library is enough to fill your forms, and when a hosted API saves you the work.

  3. A JSON data card connected by a line to a PDF form with filled fields, a ticked checkbox and a signature

    5 min read

    Best PDF form filling APIs (2026)

    DocSpring, Anvil, PDF.co, Adobe PDF Services, and Nutrient compared for filling fillable PDFs and flat PDFs.

Try DocSpring with your own documents

Upload a PDF or a Word document, or create an HTML template, then send a test request. No credit card needed.

Blog posts and product news by email

New articles and product changes, once a month.