Migrating from a static site to Ghost 👻

Until now I was using my own SSG (static site generator) called genox to deploy oxal.org. Which I absolutely adore. Even now. It's one of things I'm super proud of (especially it's name 🤪, and I still use it for many other websites.

But still, STILL I ported my blog to use ghost.org

Why?

Three words. Ease of publication.

I found that writing posts inside of Emacs makes me lazy. It doesn't feel like writing. It feels like I am coding.

I don't know how to explain it. But I just never ended up writing posts locally on my machine. Maybe I won't do it now either, but this was a big enough bottleneck that it's worth optimizing for. What use is a static blog without content?

Ghost gives me a whole bunch of things which makes me EXCITED. Beautiful editor. Ability to make edits quickly. Copy paste things. Upload images easily. Paste images from clipboard. Auto image compression. No need for manual git commits 🙈. And so much more.

So if I think that my giddy excitement over ghost's proper web interface will allow me to make 1 extra post per month, this is a net win for me.

Technical details

My previous site was a static site. Now it's not 🤮 . But wait. I already host a lot of services on my Hetzner VPS. So just one more right? And honestly, hosting Ghost using docker is dead simple.

Markdown to mobiledoc

In genox, all my posts were in a nested directory full of markdown files. I needed to convert them into a json file with mobiledoc fields.

But mobiledoc support is pathetic. Finding direct markdown to mobiledoc was taking me more than 5 minutes so I instead converted markdown to html first (which is already being done by genox) and then convert html to mobiledoc using Ghost's migrate tool.

Adding a genox -> json export was really easy. It literally took me like 5 minutes, thanks to genox 🔥

# site is a global snapshot of the entire knowledge of the site which genox has
def ghost_exporter(site):
    posts = []
    for path, post in site.items():
        data = {}
        if path.startswith("blog/"):
            if post.get('date'):
                data['title'] = post['title']
                data['slug'] = post['slug']
                data['status'] = "published"
                data['published_at'] = int(datetime.combine(post['date'], datetime.min.time()).timestamp()) * 1000
                data['html'] = post['content']
                posts.append(data)
                
    ghost = {"db": [{"meta": ..., "data": {"posts": posts}}]}
    with open('ghost.json', 'w', encoding='utf-8') as f:
        json.dump(ghost, f, ensure_ascii=False, indent=4)

This outputs a single json file which is in a format acceptable by ghost.

{
	"db": [
    {
        "meta": {
            "exported_on": 1635705000000,
            "version":"4.32.0"
        },
        "data": { ... }
    }]
}

I then followed the docs here to covert the above json with html fields to mobiledoc fields. After that I imported it from the ghost dashboard, and Voila!

Ghost 👻 + Docker 🐳 = 💙

Ghost has an official docker image which stores all data in a volume, including the database which is stored in a single sqlite file.

I ran the ghost instance using docker with a custom mounted volume which is tracked by git (including the sqlite file):

docker run -d --name oxal-ghost -p 2368:2368 -v ~/ghost:/var/lib/ghost/content -e NODE_ENV=development ghost:alpine

For production I use portainer on my Hetzner server. But for now I'm just running a simple docker command using my in-built ark utility which controlls my server.

docker run -d --name oxal-ghost \
       -p 2368:2368 \
       -v /srv/ox/oxal-ghost:/var/lib/ghost/content \
       -e url=https://oxal.org \
       ghost:alpine
       
# to update the container
docker stop oxal-ghost && docker rm oxal-ghost

Ghost theme

Ofcourse I like my existing design too much, so I used a base theme and used sakura to override most parts of it.

Serving with Caddy server

Some path on my site still needed to be static, but rest of the paths need to be forwarded to ghost. I acommplish that using the following Caddyfile:

oxal.org {
        handle /projects/sakura/* {
                file_server {
                        root /srv/ox/oxal.org
                }
        }

        handle /tmp/* {
                file_server {
                        root /srv/ox/oxal.org
                }
        }
        handle {
                reverse_proxy localhost:2368
        }
}

Backup strategy

The directory /srv/ox/oxal-ghost is a git repo. I created a GitHub DEPLOY KEY with write access.

Added a cron job to add, commit, and push all the changes to the repo every 1 hour. This way the data/ghost.db file and all the uploaded images will always be backed up. This way migrating to a new server should just be a git pull away.

Mitesh Shah

Read more posts by this author.