Introducing Post2Ghost and "Articles as Code" for a GitOps Workflow in Ghost

A GitOps inspired workflow for “articles as code”! From your favourite editor and Git straight to the API. Stop the copy and paste madness and start using Post2Ghost! Readily available as a Python3 CLI app on GitHub.

Confessions of a Markdown Aficionado

Markdown is amazing! When it comes to distraction free writing, there’s little for me that beats its simplicity, yet at the same time bears its powers.

Markdown Logo

Just look at a Markdown cheat sheet — eleven basic and fairly intuitive concepts, most of which you may already be familiar with, and you’ve mastered Markdown. And if you do reach the end of the Markdown road, then you can always turn on the HTML motorway and that should get you to where you want to go.

Most important of all, Markdown allows me to focus on what matters when writing — the content. Formatting takes a back seat but it’s always close enough when I need it to logically arrange content.

I have to confess that since I’ve discovered Markdown about a decade ago, I’ve never looked back. Ever since, all my articles have been written in Markdown. And I would do it all again in a heartbeat.

Confessions of a Ghost Aficionado

Ghost is amazing! It’s simplistic. It’s elegant. It’s lightweight. It’s not PHP based. What more do you want from a publishing platform when starting a new blogging venture?!

Ghost Logo

Best of all, Ghost ships with an editor that makes Markdown a first class citizen. So, for me Ghost was a natural choice when it came to blogging platform. It allowed me to marry the simplicity of writing in Markdown with the simplicity of publishing in Ghost.

Ever since I’ve started publishing articles with Ghost, I’ve never looked at other blogging platforms again.

Confessions of a GitOps Aficionado

Now, I have to also admit that I prefer a Git centric workflow. Everything as code. Everything in Git. From development to deployment.

Git Logo

I like to version control important things in Git. Not my entire home directory but the things that really matter to me. Like source code. And articles. Wait? Articles! Yes, articles!

Version controlling articles gives me traceability, repeatability, and — most importantly — peace of mind once I’ve pushed it to a remove Git repository. We have so many things “as code” these days. There’s “infrastructure as code” and if this is the “everything as code” era, then this may as well be “articles as code”.

Articles as Code

With “articles as code”, everything originates in Git. Articles are written in a development environment using your personal favourite editor of choice (I’m currently writing in Visual Studio Code but I’m also using Atom in parallel for specific tasks) before being released to Ghost. Just like you would release any other code into production. Minus the testing, staging, promoting, and toll-gating — for now.

Code on MacBook Air

This allows me to build a Git centric workflow where I can rapidly release articles to Ghost. Essentially, it decouples writing articles from releasing them. This decoupling comes in very handy when quickly populating a Ghost test installation with selective articles (even though I could probably use the restore API for that as well when preselecting a fixed set of test articles). Moreover, I can also publish the same article to multiple Ghost installations. The options are only limited by imagination.

Well, that and the fact that there’s a gap between the final article in Git and it being inside the editor in Ghost, ready for publication. From all I could find, this is still a fairly manual process as in

  1. Upload all images.
  2. Copy the final Markdown article into the Ghost editor.
  3. Detect and fix a typo in the Ghost editor.
  4. Realise that the change needs to be made in Git as well.
  5. Make the change locally, commit everything to Git, copy-and-paste the now-hopefully final Markdown article into the Ghost editor
  6. Go back to 2 until all typos, typesetting problems, and bugs have been ironed out (or skip this step when not caring about these things)
  7. Repeat steps 2-6 again for all metadata
  8. Publish article
  9. Enjoy brief moment of accomplishment followed by victory dance

So, goodbye GitOps inspired workflow?! Goodbye, “articles as code”?!

I’d say No! Use Post2Ghost and stop the copy and paste madness!


Meet the Python3 CLI application Post2Ghost which allows to upload articles written in Markdown to draft blog posts in Ghost. For this, the application is using the Ghost v0.1 Public API. Additionally, tags can be maintained in Ghost using plain JSON dictionary files.

Python Logo

Post2Ghost allows for a Git centric workflow where articles are authored in Markdown files before being programmatically released to draft articles in Ghost.

This application is used by the authors of How Hard Can It Be?! on a regular base.

You Have

Before you can use Post2Ghost out of the box, you need

  • access to a Ghost installation with the Ghost v0.1 Public API enabled (available in up to and including Ghost release 2.10.1)
  • a user account in the Ghost installation
  • a recent version of Python3
  • the ghost-client Python package available at https://pypi.org/project/ghost-client/

Moreover, you probably also have a blog post written in Markdown ready to be uploaded.

You Want

Using Post2Ghost allows you to

  • leverage the simplicity of distraction free writing using Markdown
  • use your favourite editor for writing articles in Markdown
  • stop the copy and past madness between your favourite editor and the Ghost Markdown editor
  • programmatically upload and update draft articles from the command line
  • build a Git centric workflow for releasing articles

Initial Setup

Unfortunately, Post2Ghost can’t just magically guess all parameters required to access your Ghost installation. It needs your help on this. In more detail, it needs the following parameters:

  1. The base_url of your Ghost installation. This is where Ghost is located. In the case of How Hard Can It Be?!, that’s https://www.how-hard-can-it.be.
  2. The client_id and client_secret of your Ghost installation. These two parameters can be found in the source code of any post (not the start page or the admin area) in your blog. Simply visit your Ghost installation, browse through the source code, and search for the two keywords. Fell free to try it with this very page.
  3. The username and password that you use to access the admin area of your Ghost installation.

All of the above parameters need to be stored in your home directory at ~/.post2ghost/config.json. This is a user wide setting by design so that Post2Ghost can be used regardless of the current directory.

In the case of How Hard Can It Be?!, the ~/.post2ghost/config.json configuration file contains

  "base_url": "https://www.how-hard-can-it.be",
  "client_id": "ghost-frontend",
  "client_secret": "65ae2ad78214",
  "username": "<redacted>",
  "password": "<redacted>"

Here, username and password have been redacted for security reasons — these are the only two parameters that should actually be treated as secrets. The remaining information is already publicly available on the website.

Blog Post Metadata

Post2Ghost allows to enrich a blog post written in Markdown with JSON metadata. Here, the JSON metadata precedes the actual blog article in the same file and is used in Ghost’s publishing options to automatically populate fields such as

  • title for the blog post
  • excerpt (this is the text alongside the article on the start page)
  • feature image (the image that is being displayed for the article on the start page)
  • slug (the URL used to reference the article)
  • tags (logical folders that the article should be filed under)

At a bare minimum, title and custom_excerpt need to be defined, as these values are propagated to the corresponding settings for

  • Search engines
  • Twitter cards and
  • Facebook cards

Here, the propagation can be overwritten by providing specific values. See the official Ghost API documentation on posts for details.

An Example

For the initial blog post How Hard Can It Be?!, the metadata is

    "title": "How Hard Can It Be?!",
    "feature_image": "images/how-hard-can-it-be.jpg",
    "custom_excerpt": "Time to give back to the community! Because technology should be simple.",
    "slug": "how-hard-can-it-be",
    "tags": ["How Hard Can It Be?!"]

Here, the feature_image is stored locally at images/how-hard-can-it-be.jpg. Note that the path to the image is relative.

The slug is manually set to how-hard-can-it-be and the tags is set to the existing tag "How Hard Can It Be?!". See also the limitations around referencing tags as outlined below.

A Note on Metadata Inside Markdown Articles

As described above, the metadata has to precede the actual blog post inside the same file.

This is a deliberate design decision in order to keep all information related to a blog post in one place. The only downside is that the metadata has to be stored in valid JSON in order to be properly detected. Most likely, your editor will get confused by the mix of JSON and Markdown.

In case of parsing errors, the upload to Ghost will fail as the minimum required metadata parameters are missing. Simply go back, fix the JSON errors, and try again. Tried and tested many times. Mostly due to silly typos or syntax errors. JSON can have a mind of its own.

Image Handling

Post2Ghost automatically detects and handles all images in a given Markdown file. For that, it replaces references to local files with references to images it has uploaded to the Ghost installation. In case an image has been uploaded by Post2Ghost before, a reference to the previously uploaded image is used. All comparisons are MD5 based and tracked in ~/.post2ghost/uploaded_images.json.

Moreover, Post2Ghost detects links to external images and leaves them untouched.

A Minimum Starter Template

A minimum starter template is located at templates/minimum-post.md and may look like

    "title": "Post2Ghost",
    "custom_excerpt": "This post was uploaded using Post2Ghost",

# Post2Ghost

This post and its metadata was uploaded using [Post2Ghost](https://github.com/dumrauf/post2ghost).

A Starter Template With More Options

A starter template that leverages an external feature_image from Unsplash alongside other options is located at templates/post.md and contains

    "title": "Post2Ghost",
    "custom_excerpt": "This post was uploaded using Post2Ghost.",
    "feature_image": "https://images.unsplash.com/photo-1548474197-fe5543f71ceb?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=1080&fit=max&ixid=eyJhcHBfaWQiOjExNzczfQ",
    "slug": "post2ghost",
    "tags": ["Ghost", "Python"]

# Post2Ghost

This post and its metadata was uploaded using [Post2Ghost](https://github.com/dumrauf/post2ghost).

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

Creating and Updating Draft Posts

After completing the initial setup described above, creating and updating a draft post follows the same process by design.

Uploading and Article

An article in /path/to/post.md can be uploaded via

python3 post2ghost.py -f /path/to/post.md

Note that all references to images inside /path/to/post.md need to be relative to path /path/to/. In other words, if you preview your article, all images should show correctly.

Updating an Article

Article /path/to/post.md can be updated using the identical command, i.e.,

python3 post2ghost.py -f /path/to/post.md

Publishing an Article

In order to publish article /path/to/post.md, please log into your Ghost installation, check that the article turned out the way you expected it to be and manually hit the Publish button in Ghost.

Managing Tags

Post2Ghost also allows to manage tags in a Ghost installation. Tags are JSON dictionaries as defined in the official API documentation that are stored inside individual files for easier versioning.

A Minimum Tag Template

A minimum tag template is located at templates/tag.json and may look like

    "name": "Your Tag",
    "description": "Description of your tag.",
    "slug": "your-tag",
    "meta_title": "Your Tag",
    "meta_description": "Description of your tag."


A tag in /path/to/tag.json can be uploaded and updated via

python3 update_tag.py -f /path/to/tag.json

Known Limitations of Post2Ghost

The following is a list of known limitations when uploading articles.

  • The metadata needs to be formatted in valid JSON as currently no JSON parsing errors are detected
  • Tags can only be referred to by name
  • Only draft posts can be updated

Feel free to contribute towards eventually removing the above limitations. This is open source after all.


Below is a list of frequently asked questions.

Why is Post2Ghost Complaining About Missing Required Keys Even Though I’ve Supplied Them in the Metadata?!

There’s a chance that your metadata JSON isn’t actually valid. This will lead to Post2Ghost not being able to detect any metadata and hence complain about missing keys.

Usually, the solution is to fix the syntax errors of the JSON metadata and try again.

What’s the *.post2ghost.json File Used For?

When uploading article post.md via Post2Ghost, a file post.md.post2ghost.json is created which acts as a receipt. It contains the information returned by the Ghost API and is used as the base for all subsequent updates to the same post.

Delete this file if you want to create a new blog post.

Why Can I Not Publish an Article Straight from Post2Ghost?

More often than enough I’ve discovered flaws when checking an uploaded draft article in Ghost that escaped me when working locally. Silly things like typos, broken links, and overrunning metadata. It just seems to be safer to check the final blog post on the real thing before making it publicly available to the world.

Which Ghost API Version is Supported by Post2Ghost?

Post2Ghost only supports the Ghost Public v0.1 API.

I Know How To Make This Better! How?

Splendid idea as this is work in progress and open source after all!

Feel free to fork, improve, push, and open a pull request so we can eventually merge it back in and make it better for everyone. Thanks!

So, How Do You Post to Ghost?!

While Post2Ghost may bridge a gap in my GitOps inspired workflow, you may be following a different workflow where Post2Ghost is of little to no use to you.


I’d like to hear from you! Feel free to comment on this article or reach out. All feedback is welcome! As always, prove me wrong and I’ll buy you a pint!


Dominic Dumrauf

An Enterprise Solution Architect by profession, an avid outdoor enthusiast by heart, and a passionate barista by choice. Still hunting that elusive perfect espresso.

Read More