Running Ghost on Azure (CLI)

As I am working on making my blog more interactive and allowing to engage more with my readers, I came across Ghost. Ghost is a blogging platform that allows content creators to easily interact with its followers. Beside a paid plan, you can also self-host it - which is of course something I am interested in as a technology geek.

Analysis

At first sight, it seems easy to deploy Ghost to an Azure Container Instance or App Service. We can just take the container from Docker Hub and install it. However, when doing this we experience some issues:

  • How will we customize files easily in the container? (containers are non-persistent by default). We could mount a storage to it
  • How to hook up a CDN?
  • How to configure it easily?

Therefore, we decided to use Azure Container Instance coupled with Azure Container Registry to deploy our container and host our personal changes. All of this together will cost us around 40 Eur per month

As for our storage for the database and our images, we want to ensure that the database is backed up correctly and that our images are persistent.

Database: We could do this by creating a remote database or we could host the .sqlite file outside the container (by e.g. mounting it on an Azure Files storage). the latter is less safe as it could occur integrity errors when the file is not closed correctly (e.g. on container crash). Therefore, it is smarter to utilize a custom database

Storage: We utilize Azure Blog Storage here with a custom Ghost adapter that syncs everything to our Azure Blob Storage.

What we will be setting up

What we are setting up today is quite straightforward:

  • Azure Container Instance (our Ghost application)
  • Azure Container Registry (our Ghost Docker image)
  • Azure MySQL Database (for our Ghost data)
  • Azure Storage Account (for the images - which we can also use for CDN purposes)
  • Azure CDN

Creating our base Azure Infrastructure

Before we can get started with creating our blog, we need some basics set-up first. So, let's start creating our base infrastructure through the Azure CLI (az cli):

SUBSCRIPTION_ID="YOUR_SUB_ID"
RG_NAME="YOUR_RG_NAME"
ACR_NAME="YOUR_ACR_NAME"
SA_NAME="YOUR_SA_NAME"
MYSQL_NAME="YOUR_MYSQL_NAME"
MYSQL_ADMIN_USER="YOUR_MYSQL_USERNAME"
MYSQL_ADMIN_PASS="YOUR_MYSQL_PASSWORD"
CDN_PROFILE_NAME="YOUR_CDN_PROFILE_NAME"
CDN_ENDPOINT_NAME="YOUR_CDN_ENDPOINT_NAME"
ACI_NAME="YOUR_ACI_NAME"
ACI_IMAGE_NAME="YOUR_ACI_IMAGE_NAME"

# Configure Azure User Account
az login
az account set --subscription $SUBSCRIPTION_ID

# Create the resource group
az group create --name $RG_NAME --location westeurope

# Create Azure Container Registry with Basic SKU
az acr create --resource-group $RG_NAME --name $ACR_NAME --sku Basic
az acr update -n $ACR_NAME --admin-enabled true

# Create an Azure Storage Account
az storage account create --resource-group $RG_NAME --name $SA_NAME --location westeurope --sku Standard_LRS
az storage container create --account-name $SA_NAME --name images

# Create an Azure CDN
az cdn profile create --resource-group $RG_NAME --name $CDN_PROFILE_NAME --sku Standard_Microsoft
az cdn endpoint create --resource-group $RG_NAME --name $CDN_ENDPOINT_NAME --profile-name $CDN_PROFILE_NAME --location westeurope \
    --origin $SA_NAME.blob.core.windows.net \
    --enable-compression --no-http

echo "Open the Azure Portal to assign your own custom domain to the CDN Profile $CDN_PROFILE_NAME and create a CDN CNAME that points to $CDN_ENDPOINT_NAME.azureedge.net"

# Create an Azure MySQL Database that is Burstable
az mysql flexible-server create --resource-group $RG_NAME --name $MYSQL_NAME --location westeurope \
    --version 8.0.21 \
    --admin-user $MYSQL_ADMIN_USER --admin-password $MYSQL_ADMIN_PASS \
    --sku-name Standard_B1ms --tier Burstable

Creating our Email Account with Mailgun

To send emails to our users we need an Email Account, typically I would use Sendgrid, but it appears that Bulk Email sending with Ghost is only supported through Mailgun. Create an account there: https://www.mailgun.com/

Note: MailGun is free to start off but they will move you to the Foundation 50k. Ensure that you move back to the Pay-as-you-Go plan after you have created your account. This will then cost you 1$ per 1.000 emails sent. You can do so here: https://app.mailgun.com/app/account/mailgun/downgrade and scroll down to "Flex"

Then continue setting up Mailgun:

  1. Create a domain https://app.mailgun.com/app/domains/new
  2. Open the domain and copy the SMTP settings (we will use those below)

Setting up Ghost

Let's start by configuring our Ghost application and the theme that comes with it by creating a new directory in our source control repository of choice named ghost/

Configuring our theme

Select a theme and clone it to the created ghost/ directory.

cd ghost
git clone https://github.com/TryGhost/Journal my-theme

We can now adapt this theme and add custom features (e.g. comments, syntax highlighting, ...)

Configuring Ghost

Next up is to configure ghost itself. This will explain ghost which settings to use on startup (such as database file, mailserver, ...). Fill in your details here as well as the mailgun configuration from earlier.


```bash
cd ghost
cat << EOF > config.json
{
    "url": "http://localhost:2368",
    "server": {
        "port": 2368,
        "host": "0.0.0.0"
    },
    "database": {
        "client": "mysql",
        "connection": {
          "host": "YOUR_MYSQL_DB_NAME.mysql.database.azure.com",
          "port": 3306,
          "user": "YOUR_MYSQL_DB_USER",
          "password": "YOUR_MYSQL_DB_PASS",
          "database": "YOUR_MYSQL_DB_NAME"
        }
    },
    "storage": {
        "active": "ghost-azure-storage",
        "ghost-azure-storage": {
            "connectionString": "YOUR_CONNECTION_STRING",
            "container": "images",
            "cdnUrl": "YOUR_CDN_URL",
            "useHttps": "true"
        }
    },
    "mail": {
        "from": "'FIRST_NAME LAST_NAME' <YOUR@EMAIL>",
        "transport": "SMTP",
        "options": {
            "host": "smtp.mailgun.org",
            "port": 587,
            "auth": {
                "user": "YOUR_USER",
                "pass": "YOUR_PASS"
            }
        }
    },
    "logging": {
        "transports": [
            "file",
            "stdout"
        ]
    },
    "process": "systemd",
    "paths": {
        "contentPath": "/var/lib/ghost/content"
    }
}
EOF

Next, create a file named routes.yaml we create this file since it will modify our path from /slug towards /YYYY/MM/DD/slug which helps with SEO

routes:

collections:
  /:
    permalink: '/{year}/{month}/{day}/{slug}/'
    template:
      - index

taxonomies:
  tag: /tag/{slug}/
  author: /author/{slug}/

Creating Ghost Container

Next, we can create our Ghost container. Create a Dockerfile in the ghost/ directory that will take care off:

  • Installing the Ghost Azure Storage adapter (for our CDN syncing)
  • Configuring our custom theme
  • Installing routes so we can access our posts through `/YYYY/MM/DD/POST_SLUG`
cd ghost/

cat << EOF > Dockerfile
# https://hub.docker.com/_/ghost
FROM ghost:5.7-alpine

WORKDIR /var/lib/ghost

# Install custom storage adapter
# https://ghost.org/docs/config/#creating-a-custom-storage-adapter
RUN npm install ghost-azure-storage
RUN mkdir -p /var/lib/ghost/content/adapters/storage 
RUN cp -vR node_modules/ghost-azure-storage /var/lib/ghost/content/adapters/storage/ghost-azure-storage

# Configure Theme
# https://github.com/TryGhost/Ghost/tree/v5.7.1/ghost/core/content/themes
# copy themes/config to container
COPY my-theme /var/lib/ghost/content/themes/my-theme
COPY config.production.json /var/lib/ghost/config.production.json

# Configure Routes
COPY routes.yaml /var/lib/ghost/content/settings/routes.yaml

EOF

Now build and push your container to the Container Registry created earlier

# Login to our Azure Container Registry
az acr login --name $ACR_NAME

# Build our container
docker buildx build --platform linux/amd64 -t $ACR_NAME.azurecr.io/ghost:latest .

# Push it
docker push $ACR_NAME.azurecr.io/ghost:latest

Creating our Ghost on Azure Container Instance

To host Ghost, we will now use Azure Container Instances:

ACR_USER=$(az acr credential show --resource-group $RG_NAME --name $ACR_NAME --query 'username' | sed 's/"//g')
ACR_PASS=$(az acr credential show --resource-group $RG_NAME --name $ACR_NAME --query 'passwords[0].value' | sed 's/"//g')
ACI_NAME="cg-ghost"
ACI_IMAGE_NAME="ghost"

az container create -g $RG_NAME --name $ACI_NAME \
    --cpu 1 --memory 1 \
    --image "$ACR_NAME.azurecr.io/${ACI_IMAGE_NAME}:latest" \
    --registry-login-server "$ACR_USER.azurecr.io" --registry-username $ACR_USER --registry-password $ACR_PASS

Full Script

To run the full script above, just use the following

SUBSCRIPTION_ID="YOUR_SUB_ID"
RG_NAME="YOUR_RG_NAME"
ACR_NAME="YOUR_ACR_NAME"
SA_NAME="YOUR_SA_NAME"
MYSQL_NAME="YOUR_MYSQL_NAME"
MYSQL_ADMIN_USER="YOUR_MYSQL_USERNAME"
MYSQL_ADMIN_PASS="YOUR_MYSQL_PASSWORD"
CDN_PROFILE_NAME="YOUR_CDN_PROFILE_NAME"
CDN_ENDPOINT_NAME="YOUR_CDN_ENDPOINT_NAME"
ACI_NAME="YOUR_ACI_NAME"
ACI_IMAGE_NAME="YOUR_ACI_IMAGE_NAME"

# Login to Azure
az login
az account set --subscription $SUBSCRIPTION_ID

# Create the resource group
az group create --name $RG_NAME --location westeurope

# Create Azure Container Registry with Basic SKU
az acr create --resource-group $RG_NAME --name $ACR_NAME --sku Basic
az acr update -n $ACR_NAME --admin-enabled true

# Create an Azure Storage Account
az storage account create --resource-group $RG_NAME --name $SA_NAME --location westeurope --sku Standard_LRS
az storage container create --account-name $SA_NAME --name images

# Create an Azure CDN
az cdn profile create --resource-group $RG_NAME --name $CDN_PROFILE_NAME --sku Standard_Microsoft
az cdn endpoint create --resource-group $RG_NAME --name $CDN_ENDPOINT_NAME --profile-name $CDN_PROFILE_NAME --location westeurope \
    --origin $SA_NAME.blob.core.windows.net \
    --enable-compression --no-http

echo "Open the Azure Portal to assign your own custom domain to the CDN Profile $CDN_PROFILE_NAME and create a CDN CNAME that points to $CDN_ENDPOINT_NAME.azureedge.net"

# Create an Azure MySQL Database that is Burstable
az mysql flexible-server create --resource-group $RG_NAME --name $MYSQL_NAME --location westeurope \
    --version 8.0.21 \
    --admin-user $MYSQL_ADMIN_USER --admin-password $MYSQL_ADMIN_PASS \
    --sku-name Standard_B1ms --tier Burstable

# Setup Ghost Theme
cd ghost
git clone https://github.com/TryGhost/Journal my-theme

# Setup Ghost Config
cd ghost
cat << EOF > config.json
{
    "url": "http://localhost:2368",
    "server": {
        "port": 2368,
        "host": "0.0.0.0"
    },
    "database": {
        "client": "mysql",
        "connection": {
          "host": "YOUR_MYSQL_DB_NAME.mysql.database.azure.com",
          "port": 3306,
          "user": "YOUR_MYSQL_DB_USER",
          "password": "YOUR_MYSQL_DB_PASS",
          "database": "YOUR_MYSQL_DB_NAME"
        }
    },
    "storage": {
        "active": "ghost-azure-storage",
        "ghost-azure-storage": {
            "connectionString": "YOUR_CONNECTION_STRING",
            "container": "images",
            "cdnUrl": "YOUR_CDN_URL",
            "useHttps": "true"
        }
    },
    "mail": {
        "from": "'FIRST_NAME LAST_NAME' <YOUR@EMAIL>",
        "transport": "SMTP",
        "options": {
            "host": "smtp.mailgun.org",
            "port": 587,
            "auth": {
                "user": "YOUR_USER",
                "pass": "YOUR_PASS"
            }
        }
    },
    "logging": {
        "transports": [
            "file",
            "stdout"
        ]
    },
    "process": "systemd",
    "paths": {
        "contentPath": "/var/lib/ghost/content"
    }
}
EOF

# Create Docker Build file
cat << EOF > Dockerfile
# https://hub.docker.com/_/ghost
FROM ghost:5.7-alpine

WORKDIR /var/lib/ghost

# Install custom storage adapter
# https://ghost.org/docs/config/#creating-a-custom-storage-adapter
RUN npm install ghost-azure-storage
RUN mkdir -p /var/lib/ghost/content/adapters/storage 
RUN cp -vR node_modules/ghost-azure-storage /var/lib/ghost/content/adapters/storage/ghost-azure-storage

# Configure Theme
# https://github.com/TryGhost/Ghost/tree/v5.7.1/ghost/core/content/themes
# copy themes/config to container
COPY my-theme /var/lib/ghost/content/themes/my-theme
COPY config.production.json /var/lib/ghost/config.production.json

# Configure Routes
COPY routes.yaml /var/lib/ghost/content/settings/routes.yaml

EOF

# Build & Push Ghost container
az acr login --name $ACR_NAME
docker build -t $ACR_NAME.azurecr.io/ghost:latest .
docker push $ACR_NAME.azurecr.io/ghost:latest

# Deploy Azure Container Instance
ACR_USER=$(az acr credential show --resource-group $RG_NAME --name $ACR_NAME --query 'username' | sed 's/"//g')
ACR_PASS=$(az acr credential show --resource-group $RG_NAME --name $ACR_NAME --query 'passwords[0].value' | sed 's/"//g')

az container create -g $RG_NAME --name $ACI_NAME \
    --cpu 1 --memory 1 \
    --image "$ACR_NAME.azurecr.io/${ACI_IMAGE_NAME}:latest" \
    --registry-login-server "$ACR_USER.azurecr.io" --registry-username $ACR_USER --registry-password $ACR_PASS