Even though I’m pretty satisfied with running my blog on a DigitalOcean droplet backed up with Cloudflare CDN, I spent some time last few months looking for an option to run Ghost on Azure. I did it just for fun because, you know, my professional background is more about Microsoft and Azure ecosystem. Additionally, I’m not fond of limited configuration options supported by the Ghost team.

This post is rather a story behind my work than a how-to article. If you just want to go straight to the business, skip the first to part and head to the end of the post.

My initial requirements for this task were quite simple:

  • run a fully operational Ghost instance using only Azure services,
  • the solution should be as cost-efficient as possible,
  • it should be easy to deploy – ideally, to create a fully automated one-click deployment.

As it never hearts to check first whether such a solution already exists, I started with some preliminary research of existing options for running Ghost on Azure.

Ghost + Azure = bumpy road

“It’s been a long, been a long bumpy road…”, Hosier - Long Bumpy Road

Way back in time, even before Ghost v1 was released, there was an offering on Azure Marketplace that allowed you to deploy Ghost as a web app on Azure App Service. It worked like a charm, had a small footprint, and was relatively cheap to run. Sadly, but these times have gone long ago. The developer who adapted Ghost codebase to work in an Azure web app abandoned that adaption, and it was removed from the marketplace. As of now, everything that Azure Marketplace can offer you on Ghost implies using Azure VM for hosting. That is definitely neither the cheapest nor the easiest way to host your blog.

Some people on the internet went with forking the Ghost repo, modifying and extending its code to run on Azure App Service. However, such examples were rare, and most of those folks sooner or later gave up on updating their code: it may be fun to merge and fix breaking changes from the upstream Ghost repository for the first few times, but not when it becomes a second full-time job.

There are still a few Azure-compatible Ghost forks (Ghost-Azure by Radoslav Gatev, Ghost-Azure by Yannick Reekmans). Nevertheless, to my mind, they are more suitable for software developers experienced with Node.js. If you are like me, not a software developer per se, it is more likely that you would prefer a solution that doesn’t require touching the code on every version update.

When the Ghost team started reducing the list of supported OS and database options for running the app, I became really suspicious about that. It seemed like they intentionally made Ghost self-hosting options harder to force people to use their managed Ghost hosting. From the business perspective, that was probably the right decision as that helps them make money. However, it made me uncomfortable with limited hosting choice.

With the announcement of Azure App Service on Linux and Web App for Containers in 2017, many Azure enthusiasts decided to give it a try and began hosting their blogs as Docker containers. For example, you can check the works of Curia Damiano, Gareth Emslie, and Jessica Deen on that. However, that imposed a new set of technical challenges. Docker containers are stateless by default, and after their restart, there is no guarantee you can access the data generated during the container lifetime.

To persist your changes like creating new posts and adding content, you should configure external storage. Azure Web App for Containers provides you with an option to enable persistent shared storage at the app level and map it to volumes in Docker Compose for multi-container apps. Unfortunately, that option doesn’t work well if you want to use SQLite as your backend database, which is the default for the Docker image.

In 2018 Microsoft announced the bring-your-own-storage preview feature for App Service. Initially, it allowed mounting both Azure File shares and Azure Blob containers in ‘read/write’ mode. That created an opportunity to put the SQLite Ghost database in a blob container and Ghost content files to a file share archiving stable working single-container Ghost deployment.

Later, the support for Azure Blob container mount points was reduced to read-only, effectively making that deployment configuration non-operational. Some people decided to move to a Kubernetes cluster like AKS to get rid of all that limitations for Azure Storage in App Service, while others switched to a managed Azure Database for MySQL or even fell back to an Azure VM hosting all Ghost components.

None of the options satisfied my initial requirements. Going full-fledged with running an AKS for a simple blog seemed like overkill. Moving from the SQLite database to a managed MySQL server was somewhat a tradeoff: staying in the PaaS category but effectively doubling hosting costs at a minimum. Falling back to hosting the blog on a VM? Come on, people, we are not in the 2000s anymore!

For a moment, I felt stuck at the same point where I started investigating this topic.

Containers for the win

“And in time, a new hope will emerge.”, Obi-Wan Kenobi’s message to the survivors of Order 66

As I mentioned, the only configuration that was close to my initial requirements was running the Ghost app from a container, placing the content files on an Azure File share, and using a managed Azure Database for MySQL as a DB. It worked for sure, but could I do better? In addition to a minimal app service plan that can run a web app for containers (Basic B1), I would have to deploy and pay for a Basic MySQL Flexible Server. It would make the hosting cost for Ghost on Azure uncompetitive the managed Ghost hosting.

In 2018, Microsoft announced multi-container support for Azure App Service. That feature is still in preview as of Dec 2020, and the multi-container support was reduced to Docker Compose only. The sample multi-container app consisting of WordPress and MySQL containers worked fine, so I decided to try the same approach for Ghost. Here is how my resulting Docker Compose configuration looks like:

For storing the persistent data, I went with using the web app persistent shared storage mounting specific folders in both containers – for MySQL database and Ghost content files (images, settings, themes, etc.). I directed the Ghost app container to use the MySQL database via the environment variables. The database password, as well as the path for WEBAPP_STORAGE_HOME, are fetched from the Application settings.

Regarding the containers, I used the default Docker image for MySQL 5.7 and my customized image for Ghost. Why use a custom Ghost image and not the official one? Well, firstly, my custom image is based on the Ghost alpine image enriched with the Application Insights components for better monitoring and traceability in Azure (the original implementation idea belongs to Gareth Emslie). Secondly, the hard-coded version of the base image works as a gatekeeper for any breaking changes and bugs the Ghost team might introduce in new releases. I can update the base Ghost image version, build a local image, and test that my deployment configuration works end-to-end.

Using the multi-container app for hosting Ghost on Azure allowed me to discontinue the managed Azure Database for MySQL in my deployment configuration and return back to a setup with an app service plan as the only one primary cost driver. Now, it was time to assemble all pieces together and automate the deployment.

The deployment configuration

“If you’re going to do operations reliably, you need to make it reproducible and programmatic.”, Mike Loukides

Naturally, deploying my Ghost configuration to Azure required a web app for containers and a suitable app service plan (Basic B1 is the cheapest option available). Besides, it would be great to leverage Application Insights for collecting the telemetry and monitoring the app. After some consideration, I decided to boost the configuration with Azure CDN, as from my experience with Cloudflare, CDN should become your best friend for offloading traffic from your website and reducing the infrastructure costs. To collect the metrics and log from all used Azure services, I needed to configure their diagnostic settings and stream that data to a Log Analytics workspace (preferably). After the hard work of encoding all settings in an ARM template and testing them, the resulting configuration looked like the following:

As you might notice from the chart, the Application Insights components depend on the Log Analytics workspace. This year, Microsoft finally provided us with an option to create workspace-based Application Insights resources for having all logs (application + infrastructure) in a single bucket.

In order for our Ghost configuration to work in the mentioned multi-container environment, we need specify some Ghost specific settings:

  • NODE_ENV– tells Ghost to start in a production mode (affects caching, database configuration, logging, etc.).
  • GHOST_CONTENTspecifies where Ghost should created the default content directory structure with settings and theme files. This variable and the next one must target the same folder.
  • pathscontentPath– technically has the same meaning as the previous setting, but it is a runtime variable telling the app where to look for the content and other required files.
  • privacy_useUpdateCheck– doing Ghost in-app updates in a container does not make a lot of sense, so by setting this variable to ‘false,’ we stop the app from checking for new versions of Ghost.
  • url – a primary URL for your website, used by Ghost to construct the site links. Without this required setting, most of the links on your website will point to the wrong location.

In addition to the application-specific settings, there are also a few settings specific to running the containers:

  • WEBSITES_ENABLE_APP_SERVICE_STORAGE– setting this environment variable to ‘true’ is the primary way of enabling the web app persistent shared storage.
  • WEBSITES_CONTAINER_START_TIME_LIMITspecifies the time the platform will wait before it restarts your container(s). From my experience, the default value of 230 seconds is not enough for this specific multi-container configuration to start successfully. That is especially true during the first run when the MySQL container needs to initialize the database engine, and the Ghost container needs to populate the application database with the initial data. I ended up with setting this variable to 460 seconds to ensure reliable deployment. Luckily, the subsequent container’s starts take far less time.
  • WEBSITES_WEB_CONTAINER_NAME– helps you explicitly specify the container in your multi-container configuration that should be open for access. For some reason (remember that multi-container support is still in preview), the fallback options don’t always work as expected, and the Ghost frontend occasionally was not available after restarts until I forced the platform to expose its container.
  • DATABASE_PASSWORD– used to pass the database password via the environment variable in the Docker Compose configuration. It is better than having your credentials in plain text in a Docker Compose file even the DB container is not exposed externally, but not as good as keeping the password in an Azure Key vault. However, for the sake of simplicity, I let it be like that cause it’s easy to fix in the future without touching the container configuration.

The settings for Application Insights are pretty well documented and hardly require an additional explanation in this context.

Referencing a single container in an ARM template is quite simple. For example, you can instruct your web app to pull and spin up a specific container image hosted on Docker Hub with the following configuration in the ‘siteConfig’ section:

Unfortunately, sourcing your Docker Compose configuration for multi-container apps in the templates is a bit complicated. If you upload such a configuration manually on the Azure portal or via Azure CLI, the format of the ‘linuxFxVersion’ property changes to some kind of encoded string with the ‘COMPOSE’ prefix. This aspect is not documented well, and the single clue is contained in GitHub Issue #32522 in Microsoft Docs. As it turns out, the encoded part is actually our Docker Compose configuration encoded into Base64 format.

To my mind, it looks like a quick&dirty fix to make the multi-container feature work in the preview, and I really hope that it will be changed in the future so that we can just reference a Docker Compose configuration by URI. Currently, as a workaround, you can upload the human-readable configuration on the portal and extract the encoded one from a reverse-engineered ARM template.

Finally, everything was in place, and I was ready to fulfill the last requirement – an automated deployment. For sure, having an ARM template with its parameter file in hand allows you to deploy the whole configuration with a single command. Nevertheless, I decided to go an extra mile and try out the Deploy to Azure button to provide the solution consumers with a more convenient wizard-like user experience. Now you can save your time and try my solution first with a ‘single’ click of a button before deciding to fork the repo and set up your own copy of it:

Deploy to Azure

For the complete deployment configuration details, check the source code in my GitHub repo.

Instead of conclusion

This work is still in progress. For example, I’m planning to add an Azure Key Vault to the configuration for security consideration and move the database password to it from the web app application settings. Also, I might switch from Azure CDN to Azure Front Door to improve the app protection with Web Application Firewall.

Apart from that, I cannot be sure that Microsoft changes the terms of the multi-container support for Azure App Service or the web app persistent storage, which are both still in preview. Although I can fall back to an Azure Database for MySQL, it would make sense for a production-like workload or a high-traffic blog, but not for 90% of personal-blog cases.

Even though the achieved result is far from ideal to my mind, I hope it can provide one more opportunity for people to host their Ghost blog on Azure.