Host Ghost 1.0 on Azure for Free

This site now runs on Ghost in Azure hosted for free1. Yes, this blog has officially become nothing but me switching blog engines and then writing about it, but hey, maybe someone finds it useful? 🤷

tl;dr

  1. Make a small change to ghost source code (for iisnode)
  2. Use Azure App Service free tier to host your blog
  3. Setup an Azure CDN with a custom domain and SSL to act as a reverse proxy
  4. Enjoy static site performance on dynamically generated content

Background

I used to run this site on Ghost a few years ago when it first came out. I switched it to use a static site generator shortly after that mostly because I thought it would be a fun little project (also performance and security and all that nonsense).

Why the Switch Back?

I guess my mistake was I never stopped following @TryGhost on twitter and they recently started tweeting about all the new features they were adding. I got a little jealous.

Also, despite all the benefits of a static site, a major drawback of that approach is the barrier to writing something. In order to write something, I usually fired up my editor on one of my machines where I had the repo already cloned. If I was on a strange machine or on a phone, forget about it. Yes, there are ways to make it easier to edit your static site in theory. In practice, I never did those things and hence I wrote less even though I’ve had ideas that I have wanted to flesh out here.

With Ghost, I’m writing this post from my phone as I watch my one year old play in his room (this is my life now). I probably never would have started writing this post otherwise if I wouldn’t have been able to do that.

Ghost on Azure App Service

Felix Rieseberg has a repo where he setup a one-click deployment to setup a Ghost blog on Azure. The only problem is that it hasn’t been updated for Ghost 1.0 yet. It is still using v0.11.9 as of this writing and Ghost 1.0 has all the new hotness.

I decided to give it a go to see if I could update it to make it work. If you want to see all of the changes I made in one place, checkout my ghost repo on github.

CLI Tool

I want to mention the new CLI tool that Ghost has introduced to setup a blog automagically. Unfortunately, I haven’t found a way to reliably use it on Azure’s app service. Also, I don’t think it would work with the code modifications that are required. So, I didn’t try to use it any further than that. But, supposedly, I guess if you have full access to a server, it makes it easier to setup…?

New Configuration System

Ghost 1.0 has a completely new configuration system which presents a problem with the way Felix configured Ghost pre-1.0. Ironically, the new config system is supposed to make it easier to support more configuration sources (e.g. env variables, json files, etc.), but in this particular case, it has made it harder.

When you host a node app in Azure, it will run through iisnode (at least on Windows - I haven’t tried any of the new native Linux stuff yet which may be easier). iisnode passes the port it wants the node app to listen on as an environment variable called PORT. Seems pretty straightforward.

Previously, Ghost just allowed you to put a config.js file in the root of your site and you could use any arbitrary javascript to configure the app. Now, you can’t do that. You have to either pass an env variable called SERVER_PORT or configure the port in a json file like this:

{
    "server": {
        "port": 69420
    }
}

Long story short, I had to modify core/server/config/index.js to pull the port specifically from the PORT env variable:

nconf.set('server:port', process.env.PORT);

I don’t know if that is the best or easiest place to make that change, but it is what I did, and it works.

The only other thing I had to do was add an iisnode.yml and a web.config file with the standard stuff in it for hosting a node app. I also included some HSTS stuff in the web.config since I only ever want to serve over HTTPS. See the repo for details.

Creating the Ghost Database

Pre-1.0 Ghost used to create a SQLite database for you automagically if it didn’t find one when first starting up. This made it incredibly easy to get started with the product, but had its limitations. With the introduction of the new CLI tool, the CLI is the thing creating the database now. And like I said earlier, I couldn’t run that on Azure App Service.

I ended up having to run the knex-migrator (which is what the Ghost CLI uses internally) manually on my machine to create the database locally and then FTP it up to Azure. I created a db.js file in the root:

var KnexMigrator = require('knex-migrator');
var knexMigrator = new KnexMigrator({
    knexMigratorFilePath: __dirname
});

knexMigrator.init();

And then, after npm install:

node db.js

This assumes you setup a config.development.json and/or a config.production.json in the root with the database you want. For me, I’m still using SQLite:

{
	"database": {
		"client": "sqlite3",
		"connection": {
			"filename": "content/data/ghost.db"
		}
	}
}

I briefly played around with trying to get everything to work with the new MySQL-in-app with Azure App Service, but you can’t access that database remotely and I couldn’t get any of the CLI tools running in the Azure Console. Also, I remembered that I don’t care and SQLite is fine.

Deploying to Azure

Once you login to your free Azure account, you will want to click on App Services:

app services
app services

And then from there click on + Add and choose a Web App:

web app
web app

You can then choose your app name and make sure to choose the Free Plan:

app service free plan
app service free plan

As of this writing, Azure defaults the node version that will run your app to some old-ass version. You’ll need to update it to the latest LTS version so that Ghost will run properly. Click on your new app service and then on Application Settings. Scroll down and make sure the WEBSITE_NODE_DEFAULT_VERSION is set to 8.9.4 (the latest LTS at the time of this writing):

node version
node version

With the correct node version in place, you’ll next want to setup deployment for your site. I like using the Github integration so that anytime I push to a particular branch, it will automatically redeploy the site. It will also npm install any dependencies during deployment.

Navigate to Deployment Options to choose your source:

deployment-source
deployment-source

From there, I chose Github and filled in all my settings:

deployment-github
deployment-github

I set my repo up with a few branches for easier maintenance. The ghost branch is where I commit the Ghost releases as they come out unchanged. The azure branch is where I made only the changes necessary to get it running on Azure. I rebase this branch off of ghost as new releases come out. I then create a separate branch for each site I deploy. e.g. I created a chadlynet branch for this site and I create other branches for family members that want their own sites. This way I can make theme customizations and tweaks to individual sites and rebase those changes off of the azure branch.

Settings

Once your site is deployed, you will want to set some environment variables. You can set any of these options, but the main one we want to set is url. Click on Application Settings under your app service and then scroll down to App Settings. You will want to set url (casing matters) to the URL of your site:

URL app setting
URL app setting

Make sure to Save after setting that setting.

Note

You don’t need to set the NODE_ENV as iisnode will set that to production for you automatically.

A Working Site

You should now have a working site at yourname.azurewebsites.net. You can start to set your site up now by logging in at /ghost. A limitation of the free tier is that you can’t set a custom domain. You are forced to use the azurewebsites.net domain. We shall get around that limitation by using Azure CDN and get some significant performance benefits along with it.

Azure CDN as a Reverse Proxy

Technically, the Azure CDN isn’t free. But it is so cheap, that it is practically free. For the standard tier, as of this writing, it is $0.087 / GB. For my site, since I am only serving text and small images, it ends up costing me on average a little less than $0.07 / month. Trust me, your shitty blog, like mine, will also not be expensive. 😜

Static Site Performance

A site like mine (one that doesn’t get updated very often) can really benefit from being served like a static site e.g. almost always from cache. If you set the cache headers right in Ghost, you can get this benefit with Azure CDN since it will act as a caching reverse proxy honoring the cache headers from your site.

To setup the caching config for Ghost, you will need to edit the config.production.json file in the root:

{
    "caching": {
		"frontend": {
			"maxAge": 72000
		}
	}
}

This tells Ghost to cache the frontend of the site (e.g. not the admin section at /ghost) at 72,000 seconds = 20 hours. By default, Ghost will set this number to zero. You will want to bump that number to whatever you are most comfortable with. The way I have it setup, when I write or edit a post, it may or may not show up for people depending on where they are for up to 20 hours. I am OK with that for the performance benefits it brings.

There is a little more nuance to this concerning ETags and things; more on that in another post.

Setup

To setup the CDN, you will want to click on the + New icon and search for / choose CDN:

choose cdn
choose cdn

From there, fill in your settings making sure to choose Standard Verizon as the pricing tier. As of this writing, Verizon is the only provider that supports SSL on custom domains. This is supposedly “coming soon” to Akamai, but it is not here yet.

create cdn
create cdn

You’ll also want to choose Web App as the origin type and choose your existing web app you created previously. You can include the CDN in the same resource group as your web app to make it easier to manage going forward.

After you create your CDN, it takes Verizon 6 hours or so to get off their ass and actually propagate your changes through their network. Until then, your new endpoint whatever.azureedge.net will return 404s. Don’t fret, just relax. Go read a book or something (it is like a movie but on paper).

If you aren’t into books, while you wait, you can also setup your custom domain. In your CDN profile, click on Endpoints and then choose the existing endpoint we just setup. From there, click on + Custom Domain. You’ll have to setup a CNAME to whatever.azureedge.net for your domain.

endpoint
endpoint

Again, the domain takes way longer to propagate than you think it should. While you are in there, you might as well take advantage of the free SSL that you can get with your domain. Click the custom domain you setup and enable HTTPS:

ssl
ssl

They will send an email to a bunch of different addresses associated with your domain. Click the link on one of those emails to confirm and setup the SSL.

ssl3
ssl3

Once that is done, it will take some time to propagate throughout the CDN network.

Update App Settings

Once you have all of this setup, your site will be exposed on 3 domains, yoursite.azurewebsites.net (the web app), yoursite.azureedge.net (the CDN endpoint), and your custom domain. Your custom domain is obviously the one you count as your site, e.g. the canonical version.

If you are worried about search engine penalties for duplicate content, don’t be. Ghost will autogenerate a canonical link element on each page of your site using the url configured in your web app’s App Settings (which we setup earlier). You will want to update that URL now to your custom domain with HTTPS. And, yes, canonical link elements work cross-domain.

Warning

You explicitly don’t want to setup 301 redirects from your web app to the canonical domain. The CDN reverse proxy would then not be able to access the web app. Just think of the azurewebsites.net version of your site as the non-cached version of your site where you can see your changes before the rest of the world sees them on your “real” site.

Final Product

Once I had all of this setup for this site, I decided to perform an experiment. I went to the public facing homepage to prime the CDN cache. I then went into the Azure portal and stopped the underlying app service. So, effectively, this site at the azurewebistes.net domain started returning 503s and became unavailable. I then refreshed the page on the public facing (CDN) site and everything still worked fine. Then, just to be sure, I opened dev tools and disabled the client cache and refreshed the page again. The server returned a string of beautiful 200s like nothing was wrong.

200s
200s

In other words, when everything is running normally, even if this site gets slammed, very little traffic will make it through to the actual Ghost app. The global CDN will relieve all that pressure from the web app and keep the site loading fast for everyone.

I dare somebody to DDOS this site.2


  1. For certain values of free.
  2. Not actually. Please leave me alone.

👋 Hi! I’m Chad. I’m a Tech Lead turned founder, but I mostly see myself as a web developer specializing in the .NET Core, React, and Node.js ecosystems. I like to build tools to make developers’ lives easier. I am currently working on a modern job platform for .NET Core and Node.js.

Webmentions

What is this?

1 Like

  • Pascal Andy 🔥 📰

24 Comments

  1. Johnathon Biersack
    Johnathon Biersack

    You're the man. You just saved me a bunch of time.

  2. olaj
    olaj

    Great post!

    However i have some issues with the "Create the DB" step. I have followed the instructions but when i run node db.js nothing happens and no DB is created anywhere. Not sure what i'm doing wrong or if i have misunderstood something. I have runned npm installed, i've also runned the install for the knex thing both with npm install -g knex-migrator --save and then only npm install knex-migrator.

    I'm pretty new to node / ghost so i might have missed something experience users takes for granted :D

    1. olaj
      olaj

      Strange, i know changed the line "knexMigratorFilePath: __dirname" to my "config.production.json" and runned, then i got an error and realised it was the "MigratorConfig.js" that probably should be specified there. Then i just changed back to "__dirname" and now it works. No idea what happened :D

    2. Chad Lee
      Chad Lee

      Yeah, if you run it the way I did in the article, you need to make sure you have a config.development.json in the root. Otherwise, ghost will just use it's defaults and ignore your config.production.json. Either that, or set the env variable NODE_ENV to production.

  3. Steve Mitchell
    Steve Mitchell

    Great step-by-step guide to getting Ghost running on Azure! Best one I have seen out there to date!

  4. John Barnes
    John Barnes

    Followed GIT deployment instructions, FTPd the database, and changed the url in AppSettings, but keep getting a 500 error. I'm wondering if it is the redirect to HTTPS...has this happened to anyone else? Banging my head trying to figure it out.

    1. John Barnes
      John Barnes

      Haven't done the CDN yet :)

  5. Akos
    Akos

    Thanks for the great tutorial.

    I've tried it and followed you step-by-step until the actual deployment - mirrored your Github repo (current state), deployed from the azure branch, created the db, copied via FTP and finally created the app setting. But now I still get HTTP 500 if I try to view my site. In the logs, I can see an error like this:
    Knex: run
    $ npm install sqlite3 --save
    Error: Cannot find module 'D:\home\site\wwwroot\node_modules\sqlite3\lib\binding\node-v57-win32-ia32\node_sqlite3.node'

    And this is indeed true. If I check my node_modules, under sqlite3\lib\binding I see a different folder on the same path called node-v48-win32-ia32, with the same file. Also tried running db.js from inside the app service through the kudu console, but it doesn't seem to make a difference.

    Do you have any suggestions?

    1. Akos
      Akos

      I just saw that someone else had the same issue in your Github repo under the closed items. That solved my problem too, sorry.
      Again, great post!

    2. Chad Lee
      Chad Lee

      Thanks, I've updated the post to make sure the node version is correct in the Application Settings. Apparently that is a step I left out that affects more people than I realized.

  6. Nadine L
    Nadine L

    I think I'm in love with this blog post. Thank you, thank you, thank you!

  7. ksnv soft
    ksnv soft

    great article and excellent work. I would like to setup my own blog but not sure where to start. Do I need to setup ghost in my local machine first in-order to create db? if yes, can I do it in windows machine. sorry if I don't make any sense. Thanks in advance.

    1. Chad Lee
      Chad Lee

      You just need to clone the ghost repo and run the db.js script as I outlined in the post in order to create the database locally. I did it all on a windows machine.

    2. ksnv soft
      ksnv soft

      thank you. will try it. have you also considered hosting on free aws? I see that many people using in that way

  8. Akos
    Akos

    So let's say you wanted to make the solution even more Azure-y by adding App Insights into the mix. What would you suggest the best place is to set it up in the Ghost source code?

    1. Chad Lee
      Chad Lee

      I would just add the applicationinsights package and follow the Getting Started instructions. You would probably want to do it at the beginning of the root index.js.

    2. Akos
      Akos

      Thanks for the tip. I was going through the source trying to insert the startup code to server/index.js and other places, but no matter what I did, it didn't work. I didn't even think about the root index.js, but it was the right solution.

  9. remo
    remo

    yaay.. I was able to setup my own blog with the help of this post. thank you very much.
    now how can I add comments plugin like here. if I setup cdn with 20 hrs cache will it work. thanks again

    1. Chad Lee
      Chad Lee

      Yes, the CDN cache is independent of any 3rd path comment service loading. The disqus comments are loaded client-side from disqus's domain.

  10. Ahmed Shehata
    Ahmed Shehata

    Hey Chad, awesome article, helped me a lot. I just have one problem, the website takes initially about 15 seconds to load for the first load, afterwards it is fine, is the machine somehow sleeping if inactive for a while? Or is this DNS related (because of the azurewebsites domain) ? Thanks!

    1. Chad Lee
      Chad Lee

      Yes, the azurewebsites free tier will spin down the app if it doesn't receive any traffic for a while. If you upgrade to a paid tier, you can enable the "Always On" option for the app so it won't do that.

      There are a number of ways to get around that:
      1. Upgrade to the paid tier like I mentioned
      2. Setup a CRON job somewhere to load your homepage (on the azurewebsites domain to bypass the CDN) every 5-10 minutes or so to keep the app alive.
      3. Don't worry about it. After your CDN cache expires, just know the first hit will take ~ 15 seconds.

    2. Ahmed Shehata
      Ahmed Shehata

      Cool, thanks a lot for the tips!

  11. MP
    MP

    Help,
    I have created the DB and ftp it to azure, configured url and node version. When I try deploy I got this strange error=

    Command: deploy.cmd
    Handling node.js deployment.
    KuduSync.NET from: 'D:\home\site\repository' to: 'D:\home\site\wwwroot'
    Copying file: 'iisnode.yml'
    Using start-up script index.js from package.json.
    Node.js versions available on the platform are: 0.6.20, 0.8.2, 0.8.19, 0.8.26, 0.8.27, 0.8.28, 0.10.5, 0.10.18, 0.10.21, 0.10.24, 0.10.26, 0.10.28, 0.10.29, 0.10.31, 0.10.32, 0.10.40, 0.12.0, 0.12.2, 0.12.3, 0.12.6, 4.0.0, 4.1.0, 4.1.2, 4.2.1, 4.2.2, 4.2.3, 4.2.4, 4.3.0, 4.3.2, 4.4.0, 4.4.1, 4.4.6, 4.4.7, 4.5.0, 4.6.0, 4.6.1, 4.8.4, 5.0.0, 5.1.1, 5.3.0, 5.4.0, 5.5.0, 5.6.0, 5.7.0, 5.7.1, 5.8.0, 5.9.1, 6.0.0, 6.1.0, 6.2.2, 6.3.0, 6.5.0, 6.6.0, 6.7.0, 6.9.0, 6.9.1, 6.9.2, 6.9.4, 6.9.5, 6.10.0, 6.10.3, 6.11.1, 6.11.2, 6.11.5, 6.12.2, 6.12.3, 7.0.0, 7.1.0, 7.2.0, 7.3.0, 7.4.0, 7.5.0, 7.6.0, 7.7.0, 7.7.4, 7.10.0, 7.10.1, 8.0.0, 8.1.4, 8.4.0, 8.5.0, 8.7.0, 8.8.0, 8.8.1, 8.9.0, 8.9.3, 8.9.4, 8.10.0, 8.11.1, 10.0.0.
    Selected node.js version 8.11.1. Use package.json file to choose a different version.
    Selected npm version 5.6.0
    Updating iisnode.yml at D:\home\site\wwwroot\iisnode.yml
    Verifying Yarn Install.
    D:\local\AppData\npm\yarnpkg -> D:\local\AppData\npm\node_modules\yarn\bin\yarn.js
    D:\local\AppData\npm\yarn -> D:\local\AppData\npm\node_modules\yarn\bin\yarn.js
    + yarn@1.7.0
    updated 1 package in 1.203s
    Installing Yarn Packages.
    'yarn' is not recognized as an internal or external command,
    operable program or batch file.
    Failed exitCode=1, command=yarn install --production
    An error has occurred during web site deployment.
    'yarn' is not recognized as an internal or external command,\r\noperable program or batch file.\r\nD:\Program Files (x86)\SiteExtensions\Kudu\73.10510.3399\bin\Scripts\starter.cmd deploy.cmd

  12. Tonny Thiatmaja
    Tonny Thiatmaja

    Hi Chad,
    Nice article! I am starting my own blog with this setup.

    I got a question about running this locally (I am trying to make some customization).
    - I forked your github repository, created a new branch off azure branch and called it myBlog branch.
    - now that I'm in myBlog branch, i ran the node db.js and it created the database fine.
    - Then, I try to do npm start and it gave me below:

    WARN Theme's file locales/en.json not found.
    INFO Ghost is running in development...
    INFO Listening on: 127.0.0.1:
    INFO Url configured as: http://localhost:2368/
    INFO Ctrl+C to shut down
    INFO Ghost boot 3.254s

    - Then I open my browser to http://localhost:2368 but it gave my "The site can't be reached" :(

    Any tips on running this locally?

    Thanks!