Hurray! If you followed Part 1 of this guide, you now have a basic highly available infrastructure in AWS ready to host your Ghost blog. If you were not really familiar with AWS before, you have already learnt a lot about some of its key components, as well as Infrastructure as Code concepts using Terraform.

It is now time to actually install Ghost on our Ubuntu image and make some additional adjustments so our website is ready to receive traffic!

Creating an EC2 profile

To troubleshoot and manage our instance, we can use the Systems Manager agent . It's already installed as part of the Ubuntu AMI, but in order to make it work the EC2 needs to have some additional permissions that we will pass through a instance profile.  

We can create a new file for this,

resource "aws_iam_instance_profile" "ec2_profile" {
  name = "ec2-profile"
  role =

resource "aws_iam_role" "ec2_role" {
  name = "ec2-role"
  path = "/"

  assume_role_policy = <<EOF
    "Version": "2012-10-17",
    "Statement": [
            "Action": "sts:AssumeRole",
            "Principal": {
               "Service": ""
            "Effect": "Allow",
            "Sid": ""

# Policy needed
resource "aws_iam_role_policy_attachment" "test-attach" {
  role       =
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"

And then mention our newly created profile in the launch configuration

resource "aws_launch_configuration" "ghost_lc" {
  name_prefix          = "ghost-lc"
  image_id             = data.aws_ami.ubuntu.image_id
  security_groups      = []
  instance_type        = var.ec2_instance_type
  iam_instance_profile = # here

  lifecycle {
    create_before_destroy = true

Providing a domain

If you don't want your blog URL to look like, you will need a proper domain name. There are many domain registration service out there, the most popular ones being Google Domains, AWS Route53 and GoDaddy. I personally use R53 as it integrates perfectly with my multiple cloud deployments, but feel free to choose the one you prefer – domain prices don't vary as much.

At the end of this tutorial, using your favorite domain name register, you will need to point at the DNS domain of the Application Load Balancer using a CNAME record. You can follow this method if you're using Route 53, and this one if you're using Google Domains.

Enabling HTTPS

If you want your website to be as secure as possible, you need HTTPS. I won't go into details here, but I recommend you to read about it if you're still not sure why.

In this guide, I am showing you how to deploy a blog behind a Load Balancer and this makes enabling SSL/TLS protocol much more complicated. I decided to not walk you through this process in this post, however I highly recommend that you look into it before you decide to make your application public facing. You will basically need to create a certificate / import it into the Amazon Certificate Manager, validate it, and add it to your new alb HTTPS listener. This process can vary according to your domain register.

If you registered your domain using Route53, you can create a hosted zone and follow this guide that explains everything really clearly. You can use the official documentation to help you get started with other registration services.

Feel free to leave a comment if you're struggling with this part, I will be happy to help. As long as HTTPS is not enabled for your website, I would recommend that you only allow traffic to your Load Balancer from your home ip.

Installing Ghost on the EC2 image

There are many different ways to install Ghost on your instance. We could use SSH or SSM and install everything manually, but I wanted to use this opportunity to take a look at EC2 user data so we can automate the deployment even further.

An EC2 instance user data allows us to run shell scripts and cloud-init directives at boot, and therefore automate application installations. Through this process, we can update the instance OS and install any needed packages automatically when the instance first starts.

Let's create a new file, /user_data/

#!/bin/bash -xe

# Send the output to the console logs and at /var/log/user-data.log
exec > >(tee /var/log/user-data.log|logger -t user-data -s 2>/dev/console) 2>&1

    # Update packages
    apt-get update && sudo apt-get upgrade -y

    # Install Nginx
    apt-get install -y nginx 

    # Add the NodeSource APT repository for Node 12
    curl -sL | sudo -E bash

    # Install Node.js && npm
    apt-get install -y nodejs
    npm install npm@latest -g

    # Install Ghost-CLI
    npm install ghost-cli@latest -g

    # Give permission to ubuntu user, create directory 
    chown -R ubuntu:ubuntu /var/www/
    sudo -u ubuntu mkdir -p /var/www/blog && cd /var/www/blog

    # Install Ghost, cannot be run via root (user data default)
    sudo -u ubuntu ghost install \
        --url      "${url}" \
        --admin-url "${admin_url}" \
        --db "mysql" \
        --dbhost "${endpoint}" \
        --dbuser "${username}" \
        --dbpass "${password}" \
        --dbname "${database}" \
        --process systemd \

First, we update and install all the packages we need, including Nginx, NodeJS and Ghost. We then proceed to the ghost installation – you can find the official documentation here. The ghost cli offers multiple arguments to customize our deployment and you can find the full list here if need additional configurations.

Back to our Terraform code, we need to update our launch configuration to integrate the user data file. This is also where we will pass the needed variables, including database credentials and the website URL.

resource "aws_launch_configuration" "ghost_lc" {
  name_prefix          = "ghost-lc"
  image_id             = data.aws_ami.ubuntu.image_id
  security_groups      = []
  instance_type        = var.ec2_instance_type
  iam_instance_profile =
  #  path to the user data file
  user_data = templatefile("${path.module}/user_data/",
      # This is pulled from the rds resource created in
      "endpoint" = aws_db_instance.default.address,
      "database" =,
      "username" = aws_db_instance.default.username,
      # !!! Remember to find a secure way to retrieve your password
      "password"  = var.mysql_password,
      "admin_url" = var.website_admin_url,
      "url"       = var.website_url

variable "website_url" {
  type    = string
  default = "http://{your_domain}"

variable "website_admin_url" {
  type    = string
  default = "http://admin.{your_domain}"

As mentioned in Part 1, do not provide your database in plain text.

And.. We are done! We now have all the resources we need to deploy our Ghost blog. Let's proceed and apply everything:

$ terraform apply


Plan: 32 to add, 0 to change, 0 to destroy.

If you get any Terraform errors after applying, you might want to double check your code for any syntax errors or variable misreferences.

To access your blog, you will need to retrieve the DNS name of your application load balancer, in EC2 > Load Balancers > Description. You can also use your registered domain name if you setup a CNAME record pointing at the load balancer DNS name.


Congratulations !

Custom themes and plugins

Now that our 'base' deployment is done, you might want to integrate custom themes found on the marketplace for your blog. This blog post describes this process, and should be pretty straightforward. But what if our instance fails and get re-created?

Every time you make custom modifications to the theme used and other static content, you can create a custom AMI of your instance and its volume. To do this, go to EC2 > Instances, click on the ghost instance, and at the top right your windows, click on Actions > Image and templates > Create image.

Create image

You can then enter an image name you can easily identify, and click on Create image. Once this is done, go to EC2 > Images > AMIs to grab your newly created image ID, so we can update our launch configuration to update the image used by our instances and remove the user_data:

resource "aws_launch_configuration" "ghost_lc" {
  name_prefix          = "ghost-lc"
  image_id             = {custom_ami_id} # Previously the ubuntu image
  # user_data = [...] 				     # Remove or comment out this block

You can keep the user_data file in case you need it for a new deployment later on. Don't forget to Terraform apply to validate your changes.

Going further

First of all, congratulate yourself for finishing this tutorial! It wasn't easy. Infrastructure as Code can be intimidating at first and feels like it's overkill, but grasping its main concepts can be a huge benefit in the long run for your applications, both in testing and production environments.

If you have a thirst for knowledge, here are some improvements you could make for this Ghost deployment (HTTPS being mandatory):

Drop a comment if you have any questions/issues!

Source code for this guide: