Self-hosting Ghost on Azure

Since I first discovered it, I've been running my blog on Ghost. But, being the cheapskate/curious developer I am, I've been self-hosting.

Ghost was never really set up to run on Azure though, and there's always been a number of hoops to jump through to make that work. My original solution was a fork of Ghost's code and the default theme with the tweaks I needed to get it running and add Disqus and such to the theme. I had this set up to auto-deploy from Git to an Azure webapp... but of course, over time, I fell behind updating from the upstream repo and my blog grew dusty.

What I wanted instead was a more lightweight approach: I didn't want the hassle of repos or manual code updates. A containerised solution made sense, but I didn't want to have to deal with managing docker hosting infrastructure either. What would have been ideal was a simple container deployment, with storage mapped out to the cloud so the data is properly persistent.

Say hello to Container Instances

Container instances are a relatively recent addition to Azure's service. They allow for lightweight containerised deployments with the bare minimum of configuration. In my case, I could deploy docker containers necessary for my blog hosting with just a couple of files and minimal configuration: all of the deployed containers are straight from the public registry, and configured through mapped volumes and environment variables. Future updates will be straightforward since I can simply redeploy the configuration to pick up the latest, updated, images.

The deployment can be specified as JSON or YAML - I used the latter as was more lightweight. I also needed a simple Nginx config, and a few Azure file shares for the data persistence.

Annoyances first

So, bad news up front. The first big issue I ran into was that, for unknown reason, the Ghost container doesn't like writing its database to an Azure file share. It can create an empty database, but the migrations always throw a MigrationsAreLockedError and I couldn't get around that (either with MySql or SqlLite). I dug around this for a while to no avail, and eventually settled on running a dedicated MySql container, which worked fine with the mapped file share.

The other problems I ran into all have one root cause: Azure file shares don't support links. Ghost's theme handling relies on creating links to function (at least in the Docker container). The same goes for Certbot, but more on that later. The solution to these is, unfortunately, to leave some non-critical data (like my blog's theme) in volatile storage on the container.

Prerequisites

The first step is to create a storage account in Azure and add some file shares to it:

  • one for the nginx config
  • one for the MySql database
  • one for the Ghost image files

You'll need the account name and key for accessing the shares; you can find those under "Access keys" in the storage account's settings in the Azure portal. You'll also need a way of pushing files to the shares - either the Azure Storage Explorer or the Azure CLI.

Nginx is going to need a config file for the site. A simple one will forward all of the public HTTP traffic to the Ghost container's port, and looks like this:

server {
  listen 80 default_server;
  listen [::]:80 default_server;
  server_name puzey.net *.puzey.net;

  location / {
    proxy_pass http://127.0.0.1:2368;
    proxy_set_header    Host                $http_host;
    proxy_set_header    X-Real-IP           $remote_addr;
    proxy_set_header    X-Forwarded-For     $proxy_add_x_forwarded_for;
  }
}
A simple nginx .conf file for this site

You can see that the Ghost container will be addressed as localhost, as are all containers in the group, but on its own mapped port, which is not available publicly. This file should go in the nginx file share.

The deployment file

Now, we need the container instance Yaml file. I'll go through this in stages - but everything in this section should be in a single file.

First, some boilerplate:

apiVersion: '2018-10-01'
tags: null
type: Microsoft.ContainerInstance/containerGroups
location: southuk
name: your-container-name

You can set the tags, location (i.e. the Azure region) and name to whatever suits you.

Next, we'll define our Azure file shares as volumes, and configure the public network profile:

properties:
  osType: Linux
  ipAddress:
    dnsNameLabel: someDnsName
    type: Public
    ports:
    - protocol: tcp
      port: 80
  volumes:
    - name: nginxconfig-volume
      azureFile:
        sharename: nginx-share
        storageAccountName: myAccountName
        storageAccountKey: myAccountKey
    - name: ghostdata-volume
      azureFile:
        sharename: mysql-data-share
        storageAccountName: myAccountName
        storageAccountKey: myAccountKey
    - name: ghostimages-volume
      azureFile:
        sharename: ghost-images-share
        storageAccountName: myAccountName
        storageAccountKey: myAccountKey

Setting the dnsNameLabel makes your site available at a url of the form someDnsName.uksouth.azurecontainer.io , which can be useful since subsequent redeployments might not reuse the same IP address. The details three volumes should match those of the storage account.

Now, we can define the containers. These are nested within the properties key, so be careful with the indenting if you're copying directly from the post. The first container will be the MySql database:

  containers:
  - name: mysql
    properties:
      environmentVariables:
      - name: MYSQL_ROOT_PASSWORD
        value: this_is_required_but_will_be_overwritten
      - name: MYSQL_RANDOM_ROOT_PASSWORD
        value: yes
      - name: MYSQL_USER
        value: some_other_user
      - name: MYSQL_PASSWORD
        value: some_other_password
      - name: MYSQL_DATABASE
        value: some_database_name
      image: docker.io/mysql:5.7
      ports:
      - port: 3306
        protocol: tcp
      resources:
        requests:
          cpu: 0.5
          memoryInGb: 0.5
      volumeMounts:
      - mountPath: /var/lib/mysql
        name: ghostdata-volume

Note that listing port 3306 in the definition maps that port to localhost for other containers in this file, without exposing it publicly. (Only the ports specified earlier are public). The Azure file share is mounted here so that the database writes into persistent storage, instead of keeping all our data within the container. Specifying the MYSQL_DATABASE environment variable means that the specified database will be created, with the defined user as admin.

We need Ghost itself, configured to use the MySql database:

  - name: ghost
    properties:
      environmentVariables:
      - name: url
        value: http://your_url_here
      - name: database__client
        value: mysql
      - name: database__connection__host
        value: localhost
      - name: database__connection__port
        value: 3306
      - name: database__connection__user
        value: your_db_user
      - name: database__connection__password
        value: your_db_password
      - name: database__connection__database
        value: your_db
      image: docker.io/ghost:3.0-alpine
      ports:
      - port: 2368
        protocol: tcp
      resources:
        requests:
          cpu: 0.5
          memoryInGb: 0.5
      volumeMounts:
      - mountPath: /var/lib/ghost/content/images
        name: ghostimages-volume

There are a few things to note here.

  • If you make the URL https: here (regardless of whether Nginx is set up to serve it), the Ghost container seems to get in a tizzy because it can't serve over SSL itself, and nothing works. The solution to this looks to be using Nginx to redirect, but this would mean every link on the blog is http: and then returns a redirect, which is horrible. That's not the only problem with SSL anyway, so it's moot (see later!).
  • The database user obviously should match whatever is configured for the MySql container.
  • The Azure file share is mounted so that any images uploaded to the blog are written to persistent storage outside of the container.

Finally, we need an Nginx instance to serve everything:

  - name: nginx
    properties:
      command:
      - /bin/sh
      - -c
      - while :; do sleep 6h & wait $$!; echo Reloading nginx config; nginx -s reload; done & echo nginx starting; nginx -g "daemon off;"
      image: docker.io/nginx:mainline-alpine
      resources:
        requests:
          cpu: 0.5
          memoryInGb: 0.5
      ports:
      - port: 80
        protocol: tcp
      volumeMounts:
      - mountPath: /etc/nginx/conf.d/
        name: nginxconfig-volume

Here, we map in the Azure file share that contains the config file. This means Nginx will actually serve the file. The port 80 exposed here is also exposed publicly, so the outside world can request the site over HTTP and Nginx will handle it.

The fiddly-looking command property means that Nginx will reload its configuration every 6 hours. That means that if the site configuration needs updating at any point, that can be done by simply uploading a new .conf file to the Azure share, and Nginx will pick it up at the next reload - rather than having to restart the container.

A note on resources

Each container has to specify a CPU and memory resource request. The sum of all these requests is what will be allocated to the container group as a whole, but each container can grow beyond its individual requested amount. So, with the definitions above, Azure will allocate 1.5 CPUs and 1.5Gb of memory to the cluster, and if Nginx and Ghost only use 0.1Gb each, MySql can fill the remaining 1.3Gb.

These resource requests will also be the major factor in the resource charge, so it's worth bearing this in mind!

Deploying

Deploying the whole thing using the Azure CLI is a doddle:

az container create --file .\my-def.yaml --resource-group some_group

Make sure that the resource group name is one that already exists (or create one before running the above).

Initially on deployment you'll likely see that the Ghost container falls over and is restarted once or twice, while the MySql server spins up, but you should then see it running cleanly. The Azure portal lets you view the status and logs of each container in the group and (where possible) you can also launch a shell in the container to poke about if there are any issues.

What about HTTPS?

My earlier hosting was a standard Azure WebApp, and I had set up the Let's Encrypt Azure Extension in order to handle SSL. This was working nicely (and I still use it for the other sites that I host)... but this isn't an option with Container Instances.

There's an obvious solution though: Let's Encrypt provide CertBot, which is a nice black-box dollop of cleverness that takes care of it all for you. There's even an official Docker image for it that should help in situations like this.

So, here's the theory: the CertBot container calls Let's Encrypt and request certificates for a configured list of domains. In order to authenticate, it has to serve back some challenge files from the domain in question, which LE will request over HTTP. (The alternative is DNS validation, but this would require me to hook things up outside of my container deployment, and I'm trying to avoid that.)

This is accomplished via another couple of file shares, mapped into both to the CertBot container and the Nginx container. This allows the challenge files and certificates that CertBot uses to be served by Nginx, requiring only a simple tweak to the .conf file and the deployment YAML.

The problem is that once Certbot has the certificates, it uses a symlink to point at the latest requested cert. (This lets it keep a history of issued certs, but have the "live" one always at the same place.) Since the directory in question is mapped to the Azure file share, the link fails. I can get the certificates for my domain, I just can't serve them automatically.

I'll be looking into this again at some point in the future, but for now I'd rather my blog was live and updated without HTTPS than languishing further while I figure it out.

Thoughts and feedback welcome!