title: Ghost-CMS-and-Hugo
tags: []
date: 2023-07-16 13:31:25
date modified: 2024-01-27 21:37:11Ghost-CMS-and-Hugo
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.
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$ worth of credits valid for 60 days (who needs that much for just 60 days?!?), and I'll get 25$ 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 https://yourdomain.com/ghost.
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).
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.
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=https://your_api_url.co >> .env<br>echo GHOST_KEY=your_content_api_key >> .env<br> |
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(posts.map(async (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 = post.tags.map(t => t.name);<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 = post.authors.map((author) => ({<br> ...author,<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:
pagetitle accessible.content/posts. If you need them somewhere else, please update the path.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> |
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!
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.
You will need 2 things:
package.json scripts:plain<br>1<br>2<br>3<br> |
JSON<br> "scripts": {<br> "generate": "node createMdFilesFromGhost.js",<br> }<br> |
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.
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:
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.
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.