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:

Ghost Editor

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, since you can't store any files on Heroku. 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 Digital Ocean.

Digital Ocean provides a 1-Click App installation for Ghost. I had never used Digital Ocean 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 Digital Ocean 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:

One last thing to mention is that we use Cloudflare to cache everything on our root domain (https://docspring.com). This means that our little $5/mo server could run a blog that handles thousands of requests per second. The server only needs to respond to one of those requests, and Cloudflare will take care of the rest. (We usually don't get that kind of traffic to our blog, but it's nice to know that getting featured on Hacker News will never bring down our site.)


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.
  • Digital Ocean 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!)