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:
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 ishttp:
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!