Running Ghost on Azure (Pulumi)

In the previous post we set-up Ghost through the Azure CLI. Now, this is of course far from ideal as we want to easily update it in the future. Luckily for us there is an amazing IaaC Tool named Pulumi that I love to use.

Introduction to Pulumi

Pulumi lets us create a scripted file that describes our Infrastructure setup (it looks a bit like Biceps) and utilizes the official ARM specification so that it is always up to date with the latest changes. Once this file has been drafted, we can simply run pulumi up to deploy our Infrastructure.

If we then change our file, Pulumi will detect the changed resources and automatically update them to the latest version.

Setting up our Pulumi Project

Installing CLI

First obviously, install Pulumi its CLI tool by following the install guide on their website once that is done, we can create a new Pulumi project by running pulumi new <project_name>

mkdir project_name
pulumi new project_name

Once this is created, we need to configure our state files.

State Files

To detect these changes, Pulumi (just as Terraform does) a state file is utilized and stored. Now, before we get started, we want to ensure that this state file is stored correctly. Therefore, we should create an Azure Storage Account manually first and copy the credentials.

We can then configure our environment, so Pulumi is able to access the Storage Account and manage the state files:

az account set --subscription YOUR_SUBSCRIPTION_ID

export PULUMI_CONFIG_PASSPHRASE=""
export AZURE_STORAGE_ACCOUNT="YOUR_STORAGE_ACCOUNT_NAME"
export AZURE_STORAGE_KEY="YOUR_STORAGE_CONNECTION_STRING"

Creating our Deployment Script

Now, we are ready to start creating our deployment script, for this create an index.ts file where we put the content you can find at the bottom of this post

This script will accept certain input parameters when we run pulumi up:

  • glb-location: The location of the resource in Azure (e.g. westeurope)
  • glb-project-name: The project name that will be used to deploy
  • glb-project-env: The environment (e.g. prd, dev, ...)
  • mail-first-name: The first name for the newsletter
  • mail-last-name: The last name for the newsletter
  • mail-email: The email for the newsletter
  • mail-mailgun-user: Your SMTP username to mailgun that will be used to send mail from (e.g. noreply@yourdomain.com)
  • mail-mailgun-pass: Your SMTP password to mailgun that will be used to send mail from
  • ghost-cdn-url: The CDN URL you will use (e.g. cdn.yourdomain.com)
  • ghost-domain: The URL your site will run on (e.g. yourdomain.com)
  • ghost-version: The version of Ghost you want to install (see Docker Hub, e.g. 5.7)
  • ghost-container-version: The version of your personal container (this is to track file changes)

Running our deployment

Finally, everything is ready to be deployed. Let's pass all the arguments above through the -c <param> option in Pulumi:

➜ pulumi up \
    --stack YOUR_STACK_NAME \
    -c glb-location=northeurope \
    -c glb-project-env=prd \
    -c glb-project-name=YOUR_PROJECT_NAME \
    -c mail-first-name=YOUR_FIRST_NAME \
    -c mail-last-name=YOUR_LAST_NAME \
    -c mail-email=YOUR_EMAIL \
    -c mail-mailgun-user=YOUR_USER \
    -c mail-mailgun-pass=YOUR_PASS \
    -c ghost-cdn-url=YOUR_CDN_URL \
    -c ghost-version=5.7 \
    -c ghost-domain=YOUR_DOMAIN \
    -c ghost-container-version=0.0.1

The deployment will now run and take around ~10 minutes (MySQL takes the longest), but once this is done you should be able to access your personal Ghost dashboard and start creating posts and sending emails!

The Entire Deployment Script

import * as pulumi from "@pulumi/pulumi";
import * as dockerBuildkit from "@materializeinc/pulumi-docker-buildkit";
import * as containerregistry from "@pulumi/azure-native/containerregistry/index.js";
import * as resources from "@pulumi/azure-native/resources/index.js";
import * as sa from "@pulumi/azure-native/storage/index.js";
import * as cdn from "@pulumi/azure-native/cdn/index.js";
import * as azure from "@pulumi/azure";
import * as fs from "fs/promises";
import * as random from "@pulumi/random";
import * as web from "@pulumi/azure-native/web/index.js";
import * as law from "@pulumi/azure-native/operationalinsights/index.js";
import * as insights from "@pulumi/azure-native/insights/index.js";

const config = new pulumi.Config();

export const glbLocation = config.require("glb-location") ?? "northeurope";
export const glbProjectName = config.require("glb-project-name");
export const glbProjectEnv = config.require("glb-project-env") ?? "tmp"; // prd, stg, dev, tst, tmp, ...
export const cfgMailFirstName = config.require("mail-first-name");
export const cfgMailLastName = config.require("mail-last-name");
export const cfgMailEmail = config.require("mail-email");
export const cfgMailMailgunUser = config.require("mail-mailgun-user");
export const cfgMailMailgunPass = config.requireSecret("mail-mailgun-pass");
export const cfgGhostCdnUrl = config.require("ghost-cdn-url");
export const cfgGhostDomain = config.require("ghost-domain");
export const cfgGhostVersion = config.require("ghost-version");
export const cfgGhostContainerVersion = config.require("ghost-container-version");

// ======================================================================
// Resource Group Configuration
// ======================================================================
const resourceGroup = pulumi.all([glbLocation, glbProjectName, glbProjectEnv]).apply(([glbLocation, glbProjectName, glbProjectEnv]) => {
  return new resources.ResourceGroup(`rg-${glbProjectName}-${glbProjectEnv}-blog-`, {
    location: glbLocation
  });
})

export const outRgName = pulumi.interpolate`${resourceGroup.name}`;
export const subscriptionId = resourceGroup.id.apply(id => id.split('/')[2]);

// ======================================================================
// Log Analytics
// ======================================================================
const lawWorkspace = new law.Workspace("law", {
  location: glbLocation,
  resourceGroupName: resourceGroup.name,
  retentionInDays: 30, // 30 is minimum
  sku: {
    name: "PerGB2018",
  },
  workspaceCapping: {
    dailyQuotaGb: 10
  }
});

// ======================================================================
// Container Registry Configuration
// ======================================================================
const acr = new containerregistry.Registry("acr", {
  resourceGroupName: resourceGroup.name,
  sku: {
    name: "Basic",
  },
  adminUserEnabled: true,
});

const acrCredentials = containerregistry.listRegistryCredentialsOutput({
  resourceGroupName: resourceGroup.name,
  registryName: acr.name,
});

export const acrName = pulumi.interpolate`${acr.name}`;
export const acrLoginServer = pulumi.interpolate`${acr.loginServer}`;
export const acrAdminUsername = acrCredentials.apply(credentials => credentials.username!);
export const acrAdminPassword = acrCredentials.apply(credentials => credentials.passwords![0].value!);

// ======================================================================
// Storage Account for Images
// https://www.pulumi.com/registry/packages/azure-native/api-docs/storage/storageaccount/
// ======================================================================
const saImages = new sa.StorageAccount("sa", {
  resourceGroupName: resourceGroup.name,
  location: glbLocation,
  sku: {
    name: "Standard_LRS"
  },
  kind: "BlobStorage",
  accessTier: "Hot"
});

const saImagesCredentials = sa.listStorageAccountKeysOutput({
  resourceGroupName: resourceGroup.name,
  accountName: saImages.name
});

const saImagesCredentialsAccessKeyName = saImagesCredentials.apply(credentials => credentials?.keys[0]?.keyName);
const saImagesCredentialsAccessKeyValue = saImagesCredentials.apply(credentials => credentials?.keys[0]?.value);

// Create blob container
const saImagesContainer = new sa.BlobContainer("images", {
  accountName: saImages.name,
  resourceGroupName: resourceGroup.name,
  publicAccess: "Blob" // https://docs.microsoft.com/en-us/azure/storage/blobs/anonymous-read-access-configure?tabs=portal
});

export const saImagesName = pulumi.interpolate`${saImages.name}`;
export const saImagesAccessKeyName = pulumi.interpolate`${saImagesCredentialsAccessKeyName}`;
export const saImagesAccessKeyValue = pulumi.interpolate`${saImagesCredentialsAccessKeyValue}`;
export const saImagesContainerName = pulumi.interpolate`${saImagesContainer.name}`;
export const saImagesConnectionString = pulumi.interpolate`DefaultEndpointsProtocol=https;AccountName=${saImages.name};AccountKey=${saImagesCredentialsAccessKeyValue};EndpointSuffix=core.windows.net`;

// ======================================================================
// CDN for Images based on Blob Storage
// ======================================================================
const cdnProfile = new cdn.Profile("cdn-profile", {
  location: glbLocation,
  resourceGroupName: resourceGroup.name,
  sku: {
    name: "Standard_Microsoft"
  }
});

// Connect it to diagnostic settings
// az monitor diagnostic-settings categories list \
//    --resource /subscriptions/SUB_ID/resourceGroups/RG_NAME/providers/Microsoft.Cdn/profiles/PROFILE_ID/endpoints/ENDPOINT_ID
const dgCdnProfile = new insights.DiagnosticSetting("dg-cdn-profile", {
  workspaceId: lawWorkspace.id,
  resourceUri: cdnProfile.id,
  logs: [
    {
      category: 'AzureCdnAccessLog',
      enabled: true,
      retentionPolicy: {
        days: 7,
        enabled: true
      }
    }
  ]
});

const cdnEndpoint = pulumi
  .all([saImagesName])
  .apply(([saImagesName]) => {
    const cdnEndpoint = new cdn.Endpoint("cdnep", {
      location: glbLocation,
      resourceGroupName: resourceGroup.name,
      profileName: cdnProfile.name,
      origins: [
        {
          hostName: `${saImagesName}.blob.core.windows.net`,
          originHostHeader: `${saImagesName}.blob.core.windows.net`,
          httpsPort: 443,
          name: "origin-storage-account"
        }
      ],
      // isCompressionEnabled: true,
      isHttpAllowed: false,
      isHttpsAllowed: true,
      queryStringCachingBehavior: "NotSet"
    });

    return cdnEndpoint;
  });



export const cdnProfileName = pulumi.interpolate`${cdnProfile.name}`;
export const cdnHelp = pulumi.interpolate`CDN: Open the Azure Portal to assign your own custom domain to the CDN Profile "${cdnProfile.name}" and create a CDN CNAME that points to ${cdnEndpoint.name}.azureedge.net`;

// ======================================================================
// MySQL 8.0 Configuration
// ======================================================================
export const dbPassword = new random.RandomString("dbPassword", {
  length: 16,
  special: false,
  upper: true,
  lower: true,
  number: true,
});

export const dbMySQLUsername = "user";
export const dbMySQLPassword = dbPassword.result;

// https://docs.microsoft.com/en-us/rest/api/mysql/flexibleserver/location-based-capabilities/list?tabs=HTTP
const dbMySQL = new azure.mysql.FlexibleServer("dbmysql", {
  resourceGroupName: resourceGroup.name,
  location: glbLocation,
  administratorLogin: dbMySQLUsername,
  administratorPassword: dbMySQLPassword,
  skuName: "B_Standard_B1ms",
  version: "8.0.21",
  zone: "1"
});
// const dbMySQLConfiguration = new azure.mysql.FlexibleServerConfiguration("dbmysq", {
//   resourceGroupName: resourceGroup.name,
//   serverName: dbMySQL.name,
//   value: "600",
// });

const dbMySQLDb = new azure.mysql.FlexibleDatabase("ghost", {
  resourceGroupName: resourceGroup.name,
  serverName: dbMySQL.name,
  name: "ghost",
  charset: "utf8mb4",
  collation: "utf8mb4_0900_ai_ci",
});

// Allow access from Azure Resources
const dbMySQLDbFirewallRule = new azure.mysql.FlexibleServerFirewallRule("mysql-allow-azure", {
  resourceGroupName: resourceGroup.name,
  serverName: dbMySQL.name,
  startIpAddress: "0.0.0.0",
  endIpAddress: "0.0.0.0"
});

// const dbFirewallRule = new db.FirewallRule("fr-allow-all-to-azure", {
//   resourceGroupName: resourceGroup.name,
//   serverName: dbMysql.name,
//   startIpAddress: "0.0.0.0",
//   endIpAddress: "0.0.0.0"
// })

// const dbMysql = new db.Server("dbmysql", {
//   location: glbLocation,
//   resourceGroupName: resourceGroup.name,
//   sku: {
//     name: "Standard_B1ms",
//     tier: db.SkuTier.Basic
//   },
//   properties: {
//     publicNetworkAccess: 'Enabled', // needs to be enabled for firewall
//     createMode: 'Default',
//     administratorLogin: cfgDbAdminUser,
//     administratorLoginPassword: cfgDbAdminPass,
//     version: db.ServerVersion.ServerVersion_8_0
//   }
// });

// // https://github.com/Azure/azure-postgresql/issues/24
// const dbFirewallRule = new db.FirewallRule("fr-allow-all-to-azure", {
//   resourceGroupName: resourceGroup.name,
//   serverName: dbMysql.name,
//   startIpAddress: "0.0.0.0",
//   endIpAddress: "0.0.0.0"
// })

// const dbMySQLDb = new db.Database("ghost", {
//   resourceGroupName: resourceGroup.name,
//   serverName: dbMysql.name,
//   databaseName: "ghost"
// });

export const dbServerName = pulumi.interpolate`${dbMySQL.name}`;
export const dbDatabaseName = pulumi.interpolate`${dbMySQLDb.name}`;
export const dbHost = pulumi.interpolate`${dbMySQL.name}.mysql.database.azure.com`;
export const dbMoniker = pulumi.interpolate`mysql://${dbMySQLUsername}@${dbHost}:${dbMySQLPassword}@${dbHost}:5432/${dbMySQLDb.name}`;

// ======================================================================
// Ghost Routes
// ======================================================================  
pulumi
  .all([])
  .apply(async ([]) => {
    const pathFile = "./container/routes.yaml";

    try {
      await fs.access(pathFile);
      console.log(`${pathFile} exist, removing it`);
      await fs.rm(pathFile);
    } catch (e) {
      console.log(`${pathFile} does not exist, creating it`);
      await fs.mkdir("./container", { recursive: true });
    }

    await fs.writeFile(pathFile, `
routes:

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

taxonomies:
  tag: /tag/{slug}/
  author: /author/{slug}/
    `);
  });

// ======================================================================
// Ghost Dockerfile
// ======================================================================  
pulumi
  .all([])
  .apply(async ([]) => {
    const pathFile = "./container/Dockerfile";

    try {
      await fs.access(pathFile);
      console.log(`${pathFile} exist, removing it`);
      await fs.rm(pathFile);
    } catch (e) {
      console.log(`${pathFile} does not exist, creating it`);
      await fs.mkdir("./container", { recursive: true });
    }

    await fs.writeFile(pathFile, `
# https://hub.docker.com/_/ghost
FROM ghost:${cfgGhostVersion}-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
    `);
  });

// ======================================================================
// Ghost Config File
// if the ghost config file does not exist, we create it with the settings
// we created earlier
// ======================================================================
const ghostConfig = pulumi
  .all([dbHost, dbDatabaseName, saImagesConnectionString, dbMySQLPassword, cfgMailMailgunPass])
  .apply(async ([dbHost, dbDatabaseName, saImagesConnectionString, dbMySQLPassword, cfgMailMailgunPass]) => {
    const pathFile = "./container/config.production.json";

    try {
      await fs.access(pathFile);
      console.log(`${pathFile} exist, removing it`);
      await fs.rm(pathFile);
    } catch (e) {
      console.log(`${pathFile} does not exist, creating it`);
      await fs.mkdir("./container", { recursive: true });
    }

    await fs.writeFile("./container/config.production.json", `
{
  "url": "https://${cfgGhostDomain}",
  "server": {
      "port": 2368,
      "host": "::"
  },
  "database": {
      "client": "mysql",
      "connection": {
        "host": "${dbHost}",
        "port": 3306,
        "user": "${dbMySQLUsername}",
        "password": "${dbMySQLPassword}",
        "database": "${dbDatabaseName}",
        "ssl": {
          "rejectUnauthorized": true
        }
      }
  },
  "storage": {
    "active": "ghost-azure-storage",
    "ghost-azure-storage": {
      "connectionString": "${saImagesConnectionString}",
      "container": "images",
      "cdnUrl": "${cfgGhostCdnUrl}",
      "useHttps": "true"
    }
  },
  "mail": {
      "from": "'${cfgMailFirstName} ${cfgMailLastName}' <${cfgMailEmail}>",
      "transport": "SMTP",
      "options": {
          "host": "smtp.eu.mailgun.org",
          "port": 587,
          "secure": false,
          "auth": {
              "user": "${cfgMailMailgunUser}",
              "pass": "${cfgMailMailgunPass}"
          }
      }
  },
  "logging": {
      "transports": [
          "file",
          "stdout"
      ]
  },
  "process": "systemd",
  "paths": {
      "contentPath": "/var/lib/ghost/content"
  }
}
      `);
  });


// ======================================================================
// Ghost Theme Requirement
// ======================================================================
pulumi
  .all([])
  .apply(async ([]) => {
    try {
      await fs.access("./container/my-theme");
      console.log("Ghost Theme was configured, continuing");
    } catch (e) {
      console.error("A ghost theme does not exist, clone it to ./container/my-theme");
      console.error("Example: 'mkdir container; cd container; git clone https://github.com/TryGhost/Journal my-theme'");
      throw new Error("GHOST_THEME_DOES_NOT_EXIST");
    };
  });

// ======================================================================
// Ghost Container
// ======================================================================
const ghostContainer = pulumi
  .all([acrLoginServer, acrAdminUsername, acrAdminPassword])
  .apply(async ([acrLoginServer, acrAdminUsername, acrAdminPassword]) => {
    const imageName = `ghost`;
    const imageNameFull = `${acrLoginServer}/${imageName}:${cfgGhostContainerVersion}`;

    return new dockerBuildkit.Image(imageName, {
      name: imageNameFull,
      platforms: ["linux/amd64"],
      dockerfile: "Dockerfile",
      context: `./container`,
      registry: {
        server: acrLoginServer,
        username: acrAdminUsername,
        password: acrAdminPassword
      },
    });
  });

export const ghostDnsNameRandomIdentifier = new random.RandomString("ghostDnsNameRandomIdentifier", {
  length: 6,
  special: false,
  upper: false,
  lower: true,
  number: true,
});

export const ghostDnsName = pulumi.interpolate`ghost-${ghostDnsNameRandomIdentifier.result}`;

// ======================================================================
// Azure App Service with Container
// ======================================================================
const appServicePlan = new web.AppServicePlan("asp", {
  resourceGroupName: resourceGroup.name,
  kind: "App",
  sku: {
    name: "B1",
    tier: "Basic",
  },
  reserved: true
});

const app = new web.WebApp("app-ghost", {
  resourceGroupName: resourceGroup.name,
  serverFarmId: appServicePlan.id,
  siteConfig: {
    appSettings: [
      {
        name: "WEBSITES_ENABLE_APP_SERVICE_STORAGE",
        value: "false",
      },
      {
        name: "DOCKER_REGISTRY_SERVER_URL",
        value: acrLoginServer,
      },
      {
        name: "DOCKER_REGISTRY_SERVER_USERNAME",
        value: acrAdminUsername,
      },
      {
        name: "DOCKER_REGISTRY_SERVER_PASSWORD",
        value: acrAdminPassword,
      },
      {
        name: "DOCKER_CUSTOM_IMAGE_NAME",
        value: ghostContainer.name,
      },
      {
        name: "WEBSITES_PORT",
        value: "2368",
      },
    ],
    alwaysOn: true,
    // Before we would specify this through linuxFxVersion
    // https://docs.microsoft.com/en-us/azure/app-service/tutorial-custom-container?pivots=container-linux
    // linuxFxVersion: `DOCKER|${ghostContainer.name}`, // az webapp list-runtimes --os-type linux -o table
  },
  httpsOnly: true,
});

// Connect it to diagnostic settings
// az monitor diagnostic-settings categories list \
//    --resource /subscriptions/SUB_ID/resourceGroups/RG_NAME/providers/Microsoft.Cdn/profiles/PROFILE_ID/endpoints/ENDPOINT_ID
const dgAppService = new insights.DiagnosticSetting("dg-ghost", {
  workspaceId: lawWorkspace.id,
  resourceUri: app.id,
  logs: [
    {
      category: 'AppServiceAppLogs',
      enabled: true,
      retentionPolicy: {
        days: 7,
        enabled: true
      }
    },
    {
      category: 'AppServiceConsoleLogs',
      enabled: true,
      retentionPolicy: {
        days: 7,
        enabled: true
      }
    },
    {
      category: 'AppServicePlatformLogs',
      enabled: true,
      retentionPolicy: {
        days: 7,
        enabled: true
      }
    }
  ]
});

export const endpoint = pulumi.interpolate`https://${app.defaultHostName}`;