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:
- Create a domain https://app.mailgun.com/app/domains/new
- 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