In a rush? Skip to the Github repo.

Content management is often a challenge when considering a static site for a project. People think that a static site is, indeed, static. However, there are a lot of great solutions out there to make your static site dynamic.

Ghost is an open source CMS backed by a non-profit company. What started as a Kickstarter campaign became one of the most powerful alternative to Wordpress. It is packed with awesome features, including a Medium-like content writing interface and a subscription platform without transaction fee. Yeah, you read that well: no transaction fee.

In this article, I’ll use Ghost as a headless CMS to manage content for a static site built with Hugo and deployed on Netlify. It is the exact setup for this blog, and I believe it works extremely well.

The goal of this article is not to compare Ghost or Hugo to other solutions, but I’ll still skim through some limits for this setup in the last section.

Setting up your Ghost blog

Ghost CMS can be used as a fully hosted solution with Ghost(Pro) , or run on your own instance. The option for the key-in-hand solution looks pretty neat. One thing I like is that, since it is run by a non-profit, you know that your monthly payments are used to improve the solution. It is a win-win situation.

Today, I’ll be setting this up on my own infrastructure. It won’t come with the CDN and all security features of the hosted solution, but it’s dead cheap and it will be used as a headless CMS anyways.

There are many ways to setup your new blog, but I used the Digital Ocean 1-Click integration. If you don’t have a Digital Ocean account and want to support this blog, you can signup here. You’ll get 100 of credits with them.

The setup is pretty straightforward.

First, you need to create a droplet (Create > Droplets). You can see a droplet as your own bursty virtual machine.

You will then need to choose an image for your new droplet. The 1-Click integration is in the Marketplace tab.

Here, the Ghost CMS is recommended for me (maybe I used it too much 😏) but it may not be for you. You can always use the search box.

Once this is selected, you can go on and fill the rest of the form. You will most probably be OK with the smallest droplet (5$/month). Select the nearest region to where you will be creating/editing your content. After you submit the form, it can take 5-10 minutes for the droplet to be initialized.

You can log into your droplet using ssh root@use_your_droplet_ip. On the first login, you will be prompted to finalize your Ghost setup. On further login, if you need to run a ghost command, you need to run sudo -i -u ghost-mgr. This will log you in as the ghost user on your instance.

As I write these lines, the 1-Click installation is setting up your Ghost blog on Ubuntu 18.04 (they just released 20.04, so that could change). I suggest you follow Digital Ocean’s tutorial to complete your Ubuntu setup. The firewall should be good and ready thanks to Ghost, but the non-root sudo user is a must.

Once you’re done, you should be able to login on

Add the integration script

I take for granted that you already have your own Hugo site. If you’re starting from scratch, I suggest you have a look at Hugo’s Starter Kits for a headstart. I love using Atlas and it is the starter kit I used for this tutorial (initial commit with Atlas setup).

Create an integration in Ghost

Head up to the Integrations tab in your Ghost CMS. This is where most automation will happen for your site. Click on + Add Custom Integration. Fill in a relevant name and a description. It will be useful in the future when you forgot everything about your current setup. And yes, it happens to the best of us.

You will need both the the Content API Key and the API URL for the next steps. You should keep these informations as secure as possible.

Set up your repo

First of all, you need a npm or yarn package. If you have no clue what I am talking about, you should check what npm is. It will probably be helpful.

Install dependencies (I added dotenv here to manage environment variables on local):

plain<br>1<br>bash<br>npm install @tryghost/content-api js-yaml fs-extra dotenv<br>

Add your environement variables to .env:

plain<br>1<br>2<br>bash<br>echo GHOST_URL= >> .env<br>echo GHOST_KEY=your_content_api_key >> .env<br>

Add the script

This piece of code is inspired by a tutorial Ghost made an integration with Vuepress. It fetches all the posts in my Ghost instance and formats them before adding the files to my Hugo site. It will run on every deployment of the website, thanks to Netlify deployment command.

Add a file named createdMdFilesFromGhost.js with the following content:

plain<br> 1<br> 2<br> 3<br> 4<br> 5<br> 6<br> 7<br> 8<br> 9<br>10<br>11<br>12<br>13<br>14<br>15<br>16<br>17<br>18<br>19<br>20<br>21<br>22<br>23<br>24<br>25<br>26<br>27<br>28<br>29<br>30<br>31<br>32<br>33<br>34<br>35<br>36<br>37<br>38<br>39<br>40<br>41<br>42<br>43<br>44<br>45<br>46<br>47<br>48<br>49<br>50<br>51<br>52<br>53<br>54<br>55<br>56<br>57<br>58<br>59<br>60<br>61<br>62<br>63<br>64<br>65<br>66<br>67<br>68<br>69<br>70<br>71<br>72<br>73<br>74<br>75<br>76<br>77<br>78<br>79<br>80<br>81<br>82<br>83<br>84<br>85<br>86<br>87<br>88<br>89<br>90<br>91<br>92<br>93<br>94<br>95<br>96<br>97<br>98<br>99<br>javascript<br>const GhostContentAPI = require('@tryghost/content-api');<br>const yaml = require('js-yaml');<br>const fs = require('fs-extra');<br>const path = require('path');<br><br>// On Netlify,these environment variables are set in the admin.<br>if (process.env.NODE_ENV !== 'production') {<br> require('dotenv').config();<br>}<br><br>const ghostURL = process.env.GHOST_URL;<br>const ghostKey = process.env.GHOST_KEY;<br>const api = new GhostContentAPI({<br> url: ghostURL,<br> key: ghostKey,<br> version: 'v3'<br>});<br><br>const createMdFilesFromGhost = async () => {<br> console.time('All posts converted to Markdown in');<br><br> try {<br> // Fetch the posts from the Ghost Content API<br> const posts = await api.posts.browse({<br> limit: 'all',<br> include: 'tags,authors',<br> formats: ['html'],<br> });<br><br> await Promise.all( (post) => {<br> let content = post.html;<br><br> const frontmatter = {<br> title: post.meta_title | post.title,<br> description: post.meta_description | post.excerpt,<br> pagetitle: post.title,<br> slug: post.slug,<br> feature_image: post.feature_image,<br> lastmod: post.updated_at,<br> date: post.published_at,<br> summary: post.excerpt,<br> i18nlanguage: 'en', // Change for your language<br> weight: post.featured ? 1 : 0,<br> draft: post.visibility !== 'public',<br> };<br><br> if (post.og_title) {<br> frontmatter.og_title = post.og_title<br> }<br><br> if (post.og_description) {<br> frontmatter.og_description = post.og_description<br> }<br><br> // The format of og_image is /content/images/2020/04/social-image-filename.jog<br> // without the root of the URL. Prepend if necessary.<br> let ogImage = post.og_image | post.feature_image | '';<br> if (!ogImage.includes('https://your_ghost.url')) {<br> ogImage = 'https://your_ghost.url' + ogImage<br> }<br> frontmatter.og_image = ogImage;<br><br> if (post.tags && post.tags.length) {<br> frontmatter.categories = =>;<br> }<br><br> // There should be at least one author.<br> if (!post.authors | !post.authors.length) {<br> return;<br> }<br><br> // Rewrite the avatar url for a smaller one.<br> frontmatter.authors = => ({<br>,<br> profile_image: author.profile_image.replace('content/images/', 'content/images/size/w100/'),<br> }));<br><br> // If there's a canonical url, please add it.<br> if (post.canonical_url) {<br> frontmatter.canonical = post.canonical_url;<br> }<br><br> // Create frontmatter properties from all keys in our post object<br> const yamlPost = await yaml.dump(frontmatter);<br><br> // Super simple concatenating of the frontmatter and our content<br> const fileString = `---\n${yamlPost}\n---\n${content}\n`;<br><br> // Save the final string of our file as a Markdown file<br> await fs.writeFile(path.join('content/posts', `${post.slug}.md`), fileString, { flag: 'w' });<br> }));<br><br> console.timeEnd('All posts converted to Markdown in');<br> } catch (error) {<br> console.error(error);<br> }<br>};<br><br>module.exports = createMdFilesFromGhost();<br>

A few things to note here:

  • An article without an author is skipped. You can indeed remove this rule but I find it handy.
  • The body of the created file is all in HTML, not in markdown. We can benefit from some of the templating done by Ghost. For example, Ghost has 3 width of images and adds a class to the image depending on the width you want. This wouldn’t be possible in pure markdown.
  • Sometimes, the title that you want indexed in Google is different dans the one on the page. That’s why I am making the pagetitle accessible.
  • All files will be rendered in content/posts. If you need them somewhere else, please update the path.

Make sure that HTML rendering is enabled

Since version 0.60.0, Goldmark is the new default library used for Markdown in Hugo. For security reasons, this new renderer omits inline HTML in Markdown by default. You can read about this design decision here. Since it is what we are using for this integration, you need to update your config.toml with:

plain<br>1<br>2<br>3<br>4<br>toml<br>[markup]<br> [markup.goldmark]<br> [markup.goldmark.renderer]<br> unsafe = true<br>

Try it on local

You should be able to run your script on local to see this integration in action by simply running node createMdFilesFromGhost.js.

Your new fetched posts should have appeared in content/posts. Congratulations!

Deploy your Hugo site

Since we don’t want to run this site everytime we make a change to the website, we need Netlify redeploy and run this script for us on every change.

Netlify build command

You will need 2 things:

  1. Add the command to your package.json scripts:
plain<br>1<br>2<br>3<br>JSON<br> "scripts": {<br> "generate": "node createMdFilesFromGhost.js",<br> }<br>
  1. Update your hugo build command in netlify.toml:
plain<br>1<br>2<br>toml<br>[build]<br> command = "npm run generate && hugo -b $URL/"<br>

Please note that this needs to be adapted to your situation and is provided as an example only.

Now, you can commit and push all your changes to your repo to test your build pipeline.

Webhook from Ghost to Netlify

Once the netlify build works properly, we need a way to notify Netlify that it needs to rebuild the site. This is what webhook are built for. Ghost CMS is particularly powerful when you use the webhook feature right.

Again, you will need 2 things:

  1. Add a build hook to your Netlify site. You can do so in Settings > Build & Deploy > Build Hooks by click “Add build hook”. Please add a descriptive name, select the appropriate branch and copy the URL.
  2. Add webhooks to your Ghost integration. Go back to the Ghost integration you added to your site earlier. In the “Webhooks” section, you need to add 3 different webhooks: Post published, Published post updated, Post unpublished. The should all link to your build hook. You can add more webhooks if you want more frequent rebuilt. Just keep in mind that you’re probably using Netlify’s free plan and you shouldn’t waste their resources on empty rebuilds.

Please note that the posts’ files will not be included in your repository. You could add them manually by running the script on local and pushing the changes. This can be useful if you want extra backup of your Ghost content. Your Ghost CMS can indeed be hacked, better be safe than sorry.

Limits of Ghost with Hugo

  • Managing your pages’ content. Since there is no way to output custom frontmatter, Ghost can’t really be used to manage the content of your pages (except for basic pages without custom frontmatter variables and a body, i.e privacy policy). You can indeed update the script to do this and follow a specific format for your page, but keep in mind the page format can’t be forced. The content of your pages is probably changing less often, and could be managed by using NetlifyCMS or Forestry.
  • Managing files and uploads. One thing I find lacking in Ghost CMS is a great upload manager. We have to give WP that they nailed this feature. In Ghost, you can neither remove images nor reuse images or documents easily. This can be dangerous if you accidently upload some content with sensitive information. You will need to SSH of FTP into your server, find the file and remove it. That file has probably been declined in multiple sizes, so make sure you delete all of them. Also, with our current Digital Ocean setup, all files are served from our Digital Ocean droplet, and not from a CDN. If you’re getting a lot of traffic, you should think about integrating your uploads with S3 or paying for Ghost(Pro).
  • Messing up formatting. Since the integration imports HTML in the body of the article instead of markdown, there can be some issue with parsing and you are left without the powerful shortcodes of Hugo. For this blog, I had issue with the formatting of code blocks. Usually, the solution is to parse and transform the HTML directly in the import process. It can get tedious when there’s a lot of posts, so a bit of creativity can be needed. 😉

Final thoughts

If you’re looking for a beautiful and powerful editor, Ghost CMS is definitely worth looking into. Coupled with Hugo, you can still keep your stack as static, secure and fast as possible.

If you enjoyed this post, please take a second to share it on Twitter.