Switching from Medium to Ghost

It seemed easy enough, if you already have a DigitalOcean or similar hosting provider. It all should be done in 5 easy steps:

  • Sign in
  • Create a droplet
  • Add docker
  • Run the pre-built Ghost image
  • There is no step 5

I already have an account, with my own docker registry and my own docker swarm (which is a lot easier to maintain and deploy to than Kubernetes), so I thought I was ready to go.

It all started rosy

Getting the pre-built Ghost image was the easy part. Next, I set up an account on a database server (because I thought it would be cleaner to store my data on an actual database server).

Luckily, setting the environment for using an external database is simple enough:

# by default, the Ghost image will use SQLite (and thus requires no separate database container)
# we have used MySQL here merely for demonstration purposes (especially environment-variable-based configuration)

version: '3.1'

services:

  ghost:
    image: ghost:1-alpine
    restart: always
    ports:
      - 8080:2368
    environment:
      # see https://docs.ghost.org/docs/config#section-running-ghost-with-config-env-variables
      database__client: mysql
      database__connection__host: db
      database__connection__user: root
      database__connection__password: example
      database__connection__database: ghost

  db:
    image: mysql:5.7
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: example

Of course I removed the db entry and pointed ghost to my database server which lives somewhere in my server cluster behind a firewall.

Next, all I had to do was navigate to /ghost/ and create an account for myself. Easy peasy.

There are a lot of free Ghost themes out there. Way more than The official Ghost marketplace makes it look like. It's just a quick Google search away.

The editor UI looks a lot like Medium, and the extra options in the (+) is a welcome addition to give the added flexibility of Markdown or even adding your own HTML inline.

Slight complexity

It turns out that some things don't come out of the box just as easy as they do with do with Medium. I had to get third party analytics because the Ghost team decided not to include that.

It's fine, though. Google provides that for free.

Downhill, fast

Things started to go downhill fast once I tried to actually use it. The attentive reader will notice the subtle syntax error in the docker-compose file which will cause it to ignore the given configuration in the environment section. I didn't look carefully at first, and spent a good hour scratching my head as to why it didn't work.

The proper environment section looks like this:

    environment:
      # see https://docs.ghost.org/docs/config#section-running-ghost-with-config-env-variables
      - database__client=mysql
      - database__connection__host=db
      - database__connection__user=root
      - database__connection__password=example
      - database__connection__database=ghost

Subtle, but crucial, because docker-compose annoyingly silently ignores the settings.

Next up: image upload. This was much worse, because it just did not work. There are many posts about errors related to this.

I stared at this error for days.

It worked fine when I ran it locally, but the image never worked.

In the end, someone noted that ghost start didn't work, but ghost run did work. Others noted that the ghost process on the prebuilt ghost image died seemingly at random.

Rolling your own

In the end, I rolled my own image.

FROM ubuntu:bionic

RUN export DEBIAN_FRONTEND=noninteractive 
RUN apt-get update
RUN apt-get install -y sudo

# Create ghost user
RUN useradd -d /var/www/ghost -M ghost
RUN mkdir -p /var/www/ghost
RUN chown ghost /var/www/ghost

# Install Node
RUN apt-get install -y curl
RUN curl -sL https://deb.nodesource.com/setup_10.x | sudo -E bash
RUN apt-get install -y nodejs

# Install Ghost
RUN npm install ghost-cli@latest -g
WORKDIR /var/www/ghost
RUN sudo -i -u ghost ghost install --no-start local

COPY ./config.production.json /var/www/ghost

# Create entrypoint
RUN echo "#!/bin/sh" > /var/www/ghost/entrypoint.sh
RUN echo "sudo -i -u ghost ghost run" > /var/www/ghost/entrypoint.sh
RUN chmod +x /var/www/ghost/entrypoint.sh

ENTRYPOINT /var/www/ghost/entrypoint.sh

It was the only way I found to fix the image upload problem and as an added bonus let me use a configuration file instead of using the environment for database configuration, which to me seems cleaner.

Redirecting to localhost?

Why is this an issue? Nobody seems to know. Again, there are a bunch of people reporting it as an issue. It seems that running in incognito mode fixes the issue. Why is this a problem? It seems that ghost sets a couple of cookies when you create your account which causes it to ignore the url configuration. Why? Who knows, but the workaround is simple: Create your account in incognito mode.

∙   ∙   ∙

I thought this was going to be a half-hour task, but it turned out to take several days of head-scratching. There are a number of little gotchas which nobody officially seems to acknowledge.

Maybe they just never actually tested the pre-built docker image. Who knows? At least rolling my own solved all the image uploading issues for me, and now I know how to work around the redirect to localhost issues.

Would I recommend Ghost to others?

I think so. I would preface the recommendation with a comment on being ready for applying quite a few workarounds to get it off the ground.

Now that it is up and running. It feels just like Medium, and it's mine. No pay wall will hold users at bay.

Adding analytics

Ghost does not come with its own analytics package. Adding Google Analytics in the Ghost code injection feature was easy enough, though:

function analytics() {
    window.dataLayer = window.dataLayer || [];
    function gtag(){dataLayer.push(arguments);}
    gtag('js', new Date());
    gtag('config', 'UA-154163656-1');
}

Slight detour

Well, the first thing that happened was that I had to add GDPR spam for all EU citizens. Luckily, SimpleGDPR came to the rescue with a non-intrusive popup:

const notice = new SimpleGDPR({
    link: '/privacy-policy/',
    float: 'bottom-right',
    callback: () => {
        notice.close();
        setCookie('gdpr', 'accepted', 356);
        analytics();
    },
});

This, I could then wrap in a geo fence so that it only annoys people I am legally required to annoy:

fetch('/client-location/').then((response) => {
    return response.json();
}).then((json) => {
    let codes = [
      'AT',
      'BE',
      'BG',
      'CY',
      'CZ',
      'DE',
      'DK',
      'EE',
      'ES',
      'FI',
      'FR',
      'GB',
      'GR',
      'HR',
      'HU',
      'IE',
      'IT',
      'LT',
      'LU',
      'LV',
      'MT',
      'NL',
      'PO',
      'PT',
      'RO',
      'SE',
      'SI',
      'SK'
    ];
    if (codes.includes(json.country_code)) {
        if (getCookie('gdpr')=='accepted') {
            analytics();
        } else {
            const notice = new SimpleGDPR({
                link: '/privacy-policy/',
                float: 'bottom-right',
                callback: () => {
                    notice.close();
                    setCookie('gdpr', 'accepted', 356);
                    analytics();
                },
            });
        }
    } else {
        analytics();
    }
});

With all this in place, I was finally able to display details around a privacy policy that nobody will ever read:

Generating a privacy policy which has all the legalese required to appease the EU is no simple feat. Luckily, many people realise this and have made privacy policy generators.

Update on referrals

Something interesting happened after I added Google Analytics to my site: I noticed that besides Reddit and Hackernews, another referrer showed up. Lobste.rs. Curious.

So I went there in search for a new promotion channel. It turns out that one does not simply sign up on Lobsters. It requires an invitation:

Not a user yet? Signup is by invitation only to combat spam and increase accountability. If you know a current user of the site, ask them for an invitation or request one in chat.
– Lobste.rs

So I went in search for the article which was posted and the poster so I could ask for the invite. After a bit of searching I found his name, only to realise that I needed an irc client because lobsters users congregate in the old-timey Freenode IRC.

Thinking that irc had moved since I used it last (some 20 years ago), I did a bit of googling, and found that somebody likes Textual:

Textual turns out to be open source, so you can just go and compile it yourself. Time to spin up Xcode:

It took a little messing around with hard-coded team ids and disabling license key checks, which Textual is nice enough explain on their Github page:

If you do not have a license key, then set the TEXTUAL_BUILT_WITH_LICENSE_MANAGER flag to 0 in the Standard Release configuration file to disable the inclusion of this code at build time.

Finally, I had a brand new irc client (even though it really didn't feel new at all):

... and finally, I am now part of a new community!