What if the first post was how to deploy the blog in the first place?

Throughout this guide, you will learn how to deploy a Ghost blog on EC2 instances behind an Auto Scaling group, RDS and Terraform for high availability and ease of management.  

In case you are wondering, we could indeed use AWS hosted solutions like Elastic Beanstalk. But where would be the fun then? By deploying your blog using this tutorial, you will also learn how to use key AWS components and deploy a basic cloud based infrastructure using Infrastructure as Code. It will be divided into multiple parts, so bear with me.

This is what it will look like:  

alt text

Pretty right? This tutorial will be free tier eligible, meaning that you shouldn't have to pay a penny to Amazon in order to make it work. However, if you're expecting high traffic, or if this blog is not for personal use, you will have to use some AWS premium features.

Requirements

  • An AWS account already setup
  • A S3 bucket already defined for the Terraform state
  • Terraform v0.14.x installed

Deploying the Infrastructure

As I mentioned above, we will use Terraform to deploy the multiple infrastructure components within AWS. Terraform is an open-source infrastructure as code software tool, and I will assume that you already know the basics to follow this guide. If that is not the case, please refer to the official doc, and how to install it on your machine. You also need to configure your credentials locally.

I will also assume that your AWS account is brand new, which mean we will have to deploy everything from scratch, including:

  • A VPC with required subnets and other networking components
  • An EC2 Auto Scaling group to self heal in case of hardware failure
  • An Application Load Balancer to direct trafic to the active instance
  • A RDS instance as our managed DB solution

This is how my basic Terraform project layout looks like:

terraform
├── alb.tf
├── asg.tf
├── backend.tf
├── provider.tf
├── rds.tf
├── variables.tf
└── vpc.tf

All the variables will be defined and populated in variables.tf following Terraform best practices, except for the backend as Terraform does not allow using variables in this block.

Once the files are created, we can start by defining the s3 bucket that we will use a the remote state and the AWS provider.

backend.tf

terraform {
  backend "s3" {
    encrypt = true
    bucket  = {your_bucket_name}
    region  = {your_aws_region}
    key     = {your_bucket_key}
  }
}

Hashicorp frequently introduces breaking changes in their new major releases, so it is recommended to stick to a specific major provider version.

provider.tf

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.0"		 # Allows only the rightmost version component to increment
    }
  }
}

provider "aws" {
  region = var.region
}

VPC

It is generally not a good thing to keep the default VPC already present in your account. In order to customize your deployment further, we can use the official Terraform vpc module: https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/latest

We will only define the resources we need: 1 public subnet for the web instance and 1 private subnet for the database tier, replicated in a second Availability Zone for redundancy.

vpc.tf

module "vpc" {
  source = "terraform-aws-modules/vpc/aws"

  name             = var.vpc_name
  cidr             = var.vpc_cidr
  azs              = var.azs
  database_subnets = var.database_subnets
  public_subnets   = var.public_subnets
    
  tags             = var.tags
}

variables.tf

variable "vpc_name" {
  description = "VPC name"
  type        = string
  default     = "ghost_vpc"
}

variable "vpc_cidr" {
  description = "VPC CIDR range"
  type        = string
  default     = "10.0.0.0/16"
}

variable "azs" {
  description = "Availability Zones"
  type        = list(string)
  default     = ["eu-west-3a", "eu-west-3b"]
}

variable "public_subnets" {
  description = "Public subnets where the ghost instances will be deployed"
  type        = list(string)
  default     = ["10.0.101.0/24", "10.0.102.0/24"]
}

variable "database_subnets" {
  description = "Private subnets where the RDS instance will be deployed"
  type        = list(string)
  default     = ["10.0.1.0/24", "10.0.2.0/24"]
}

That will be enough for the networking part. The module provided by Hashicorp will take care of the needed route tables, route associations and the internet gateway.

RDS

RDS also has a free tier that allows us to run a managed MySQL database for an entire year for free, as long as we use a db.t2.micro instance. For the engine version, Ghost only supports MySQL 5.7 and lower as of January 2021.

It is not best practices to write your database password in the variables.tf and I won't go into more details here so I would recommend that you read this blog post to use the solution that fits you best.

Also note that I am starting to use the merge function to customize tags locally, so the resources are easily identifiable in the long run. Global tags will be defined in variables.tf.

rds.tf

resource "aws_db_instance" "default" {
  allocated_storage      = 20
  storage_type           = "gp2"
  engine                 = "mysql"
  engine_version         = var.mysql_engine_version        
  instance_class         = var.mysql_instance_class
  name                   = var.mysql_name
  username               = var.mysql_username
  password               = var.mysql_password
  db_subnet_group_name   = aws_db_subnet_group.default.name
  parameter_group_name   = var.mysql_parameter_group_name
  vpc_security_group_ids = [aws_security_group.mysql_sg.id]
}

resource "aws_db_subnet_group" "default" {
  name       = "main"
  subnet_ids = [module.vpc.database_subnets[0], module.vpc.database_subnets[1]]

  tags = merge(var.tags,
    {
      "Name" = "mysql-subnet-group"
  })
}

resource "aws_security_group" "mysql_sg" {
  name        = "mysql_sg"
  description = "mysql_sg"
  vpc_id      = module.vpc.vpc_id


  ingress {
    from_port       = 3306
    to_port         = 3306
    protocol        = "TCP"
    security_groups = [aws_security_group.ghost_asg.id]
  }

  egress {
    from_port       = 3306
    to_port         = 3306
    protocol        = "TCP"
    security_groups = [aws_security_group.ghost_asg.id]
  }

  tags = merge(var.tags,
    {
      "Name" = "mysql-subnet-group"
  })
}

variables.tf

variable "mysql_engine_version" {
  description = "Versions available: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_MySQL.html"
  type        = string
  default     = "5.7"
}

variable "mysql_instance_class" {
  type    = string
  default = "db.t2.micro"
}

variable "mysql_name" {
  type    = string
  default = "ghostdb"
}

variable "mysql_username" {
  type    = string
  default = "admin"
}

variable "mysql_password" {
  type    = string
  default = "{your_really_good_password}"
}

variable "mysql_parameter_group_name" {
  type    = string
  default = "default.mysql8.0"
}

Auto-Scaling

In AWS, Auto Scaling is free of charge. You only pay for the resources that will be scaled, so why would you not use it by default? I personally think that it is best practices to always try to deploy an auto scaling group in front of your instances, even though you know that one instance is enough.

Ghost cannot be clustered anyway, so spawning multiple instances is not recommended but deploying an ASG still has advantages: in case of hardware failure, the application will be able to heal itself, potentially in a different AZ if needed. Ghost is already highly available by default but if your instance cannot handle the traffic in the future, you can scale vertically and increase your instance size (not free tier eligible).

The first step is to create a launch configuration, which will be used as the template for our EC2 instances using the latest Ubuntu AMI. It will then be referred by the autoscaling_group resource. Additionally, note that we will only allow traffic to the autoscaling group from our future load balancer to restrict traffic as much as possible. Don't forget the target_group_arns resource that associates the ALB and the ASG together.

To make sure that the ASG always keep 1 healthy instance at all time, we need to define the asg_min_size and asg_max_size variables value to 1.

asg.tf

data "aws_ami" "ubuntu" {
  most_recent = true

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]   # Retrieves the latest approved image  }
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }

  owners = ["099720109477"] # Canonical
}

resource "aws_launch_configuration" "ghost_lc" {
  name_prefix     = "ghost-lc"
  image_id        = data.aws_ami.ubuntu.image_id
  security_groups = [aws_security_group.ghost_asg_sg.id]
  instance_type   = var.ec2_instance_type

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_autoscaling_group" "ghost_asg" {
  name                 = "ghost-asg"
  launch_configuration = aws_launch_configuration.ghost_lc.name
  max_size             = var.asg_max_size
  min_size             = var.asg_min_size
  vpc_zone_identifier  = [module.vpc.public_subnets[0], module.vpc.public_subnets[1]]

  # Associate the ASG with the Application Load Balancer target group.
  target_group_arns = [aws_lb_target_group.ghost_lb_tg.arn]

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_security_group" "ghost_asg_sg" {
  name        = "ghost-asg-sg"
  description = "Security group for the ghost instances"
  vpc_id      = module.vpc.vpc_id

  ingress {
    description = "Ingress rule for http"
    from_port   = 2368		# Ghost default port
    to_port     = 2368      # Ghost default port
    protocol    = "tcp"
    # Security group that will be used by the ALB, see alb.tf
    security_groups = [aws_security_group.ghost_lb_sg.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = merge(var.tags,
  {
    "Name": "ghost-asg-sg"
  })
}

variables.tf

variable "ec2_instance_type" {
  type    = string
  default = "t2.micro"
}

variable "asg_max_size" {
  type    = string
  default = 1
}

variable "asg_min_size" {
  type    = string
  default = 1
}

Application Load Balancer

To balance traffic among EC2s, AWS currently has three different options:

That being said, an ALB is our best bet here. It supports many different features for web applications even though we won't need it for the purpose of this guide. It is also free tier eligible. To make it work, we will need a listener that listens on port 80 that forwards the traffic to the instances via a target group – which is an additional layer of flexibility provided by AWS: target groups can integrate health checks, session stickiness etc.

alb.tf

resource "aws_lb" "ghost_alb" {
  name               = "ghost-alb"
  load_balancer_type = "application"
  security_groups    = [aws_security_group.ghost_lb_sg.id]
  subnets            = [module.vpc.public_subnets[0], module.vpc.public_subnets[1]]

  tags = merge(var.tags,
    {
      "Name" = "ghost-alb"
  })
}

resource "aws_lb_listener" "ghost_lb_listener" {
  load_balancer_arn = aws_lb.ghost_alb.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.ghost_lb_tg.arn
  }
}


resource "aws_lb_target_group" "ghost_lb_tg" {
  name                 = "ghost-tg"
  port                 = 80
  protocol             = "HTTP"
  deregistration_delay = 180
  vpc_id               = module.vpc.vpc_id

  health_check {
    healthy_threshold = 3
    interval          = 10
  }

  tags = merge(var.tags,
    {
      "Name" = "ghost-alb"
  })
}

resource "aws_security_group" "ghost_lb_sg" {
  name   = "ghost-sg-alb"
  vpc_id = module.vpc.vpc_id

  # Accept http traffic from the internet
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = merge(var.tags,
    {
      "Name" = "ghost-alb-sg"
  })
}

Now that we are done defining all the resources we need, you can apply the Terraform code to deploy all the resources. However, we will have to recreate the ASG launch configuration in Part 2 when we modify the user_data.

$ terraform init
$ terraform apply

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

Going further

We are done with the infrastructure part! We now have a highly available, cloud based infrastructure deployed ready to host our blog.

Part 2 will be focus on installing ghost on the Ubuntu image automatically through EC2 user-data, creating custom AMI and more. Stay tuned!

Source code for this guide: https://github.com/FlorianValery/aws-ghost-deployment