DEV Community

Cover image for Part 4 - Wordpress EC2 instance in ASG with RDS database and ALB- Awesome AWS CDK
Emmanuel K
Emmanuel K

Posted on

Part 4 - Wordpress EC2 instance in ASG with RDS database and ALB- Awesome AWS CDK

Introduction

Hot off the heels (more than one month ago lol) of the last post, we're going to provision better infrastructure for our Wordpress powered website using the AWS CDK!

In this tutorial, you will learn how to:

  • setup a Wordpress EC2 instance in an Autoscaling Group (ASG) that is attached to an Application Load Balancer (ALB)
  • setup AWS RDSMySQL database using the CDK
  • place your RDS MySQL database in an isolated subnet in a custom VPC for better security
  • SSH into the Wordpress instance using AWS SSM and IAM credentials
  • add CI/CD to your deployment so that you can deploy to multiple environments via Github actions

All of this will be done in the CDK without needing you to even open the AWS web console. How awesome is that?

The repository of this project is emmanuelnk/wordpress-ec2-rds. You can find all the final code files here.

Alright, let's go!

Abbreviations

I may use certain abbreviations throughout this tutorial. When using AWS generally, these abbreviations are quite common due to the insanely verbose names AWS gives its services

Setup

Setup AWS configuration and CDK

Create a new project

  • Make a new directory and init a new TS project:

    mkdir wordpress-ec2-rds && cd wordpress-ec2-rds
    cdk init --language typescript
    
  • NOTE: Sometimes it so happens that the cdk init installs an old verison of cdk-core. A version mismatch between @aws-cdk/core and @aws-cdk/aws-SOME_SERVICE can cause errors in Typescript.

  • If you get any weird TS errors while writing CDK code, try checking package.json to ensure you're using the latest version of @aws-cdk/core and that that version matches exactly with other CDK packages e.g. below aws-core and other aws-cdk packages are all on version 1.102.0

        // package.json
    
        "dependencies":  {
            "@aws-cdk/aws-ec2":  "^1.102.0",
            "@aws-cdk/aws-rds":  "^1.102.0",
            "@aws-cdk/aws-secretsmanager":  "^1.102.0",
            "@aws-cdk/core":  "^1.102.0",
            "dotenv":  "^8.4.0",
            "source-map-support":  "^0.5.16"
        }
    
  • To be on the safe side, everytime you start a new CDK project, just run:

    npm install @aws-cdk/core@latest
    

Project structure

In the last tutorial, we put all of our infrastructure in one file. This is fine if you're provisioning a few resources. However it's always better to split your resources logically into different files to make the project easier to manage.

This time, we will create a folder called constructs (a construct is just a particular class that is created from a base service class) i.e. we will then create the following files:

    mkdir lib/constructs
    touch lib/constructs/ec2.ts
    touch lib/constructs/alb.ts
    touch lib/constructs/rds.ts
    touch lib/constructs/vpc.ts
    touch lib/config.ts
    touch .env
Enter fullscreen mode Exit fullscreen mode

where each construct file will contains a specfic AWS service. These constructs will then be imported into the base infrastructure file that the CDK created for us (lib/wordpress-ec2-rds-stack.ts)

We also create a config.ts file to hold our configurations and a .env file to contain our envrionment variables.

(Optional) add a CI file for Github Actions. At the end of the tutorial I will show you how to deploy to either or both prod and dev using Github Actions

    mkdir -p .github/workflows/deploy.yml
Enter fullscreen mode Exit fullscreen mode

These are thus the files we will concentrate on:

├── bin
│   └── wordpress-ec2-rds.ts       # entry file
├── lib
│   ├── config.ts                  # stack and account config
│   ├── constructs
│   │   └── ec2.ts
│   │   └── rds.ts
│   │   └── alb.ts
│   │   └── vpc.ts
│   └── wordpress-ec2-rds-stack.ts # where we import the constructs
├── test
│   └── wordpress-ec2-rds.test.ts  # where we test our infrastructure
├── .github
│   ├── workflows
│   │   └── deploy.yml             # CI/CD config file (Github Actions)
└── .env                           # environment variables
Enter fullscreen mode Exit fullscreen mode

Config

  • Let's install some required dependencies first

    npm install dotenv
    
  • Let's write our configuration file (lib/config.ts):

    // lib/config.ts
    import  *  as  dotenv  from  'dotenv'
    
    // load our environment variables from .env
    dotenv.config()
    
    // this will be used in resource names
    const  stage  =  process.env.STAGE  ||  'dev'
    
    export  const  config  =  {
        // we'll use this prefix to help name our provisioned resources
        projectName:  `wordpress-ec2-rds-${stage}`,
        stage,
        stack:  {
            account:  process.env.AWS_ACCOUNT_NUMBER,
            region:  process.env.AWS_REGION  ||  'us-west-2'
        },
        deployedBy:  process.env.DEPLOYED_BY  ||  process.env.USER,
    }
    
  • Let's create our .env file. The variables below are used to aid deployment from localhost as you will see later.

    STAGE=dev
    AWS_ACCOUNT_NUMBER=XXXXXXXXXXXXX
    AWS_REGION=us-west-2
    DEPLOYED_BY=john.doe
    
  • Let's modify our entry file (bin/wordpress-ec2-rds.ts) from this

    // bin/wordpress-ec2-rds.ts
    #!/usr/bin/env node
    import  'source-map-support/register';
    import  *  as  cdk  from  '@aws-cdk/core';
    import  {  WordpressEc2RdsStack  }  from  '../lib/wordpress-ec2-rds-stack';
    
    const  app  =  new  cdk.App();
    
    new  WordpressEc2RdsStack(app,  'WordpressEc2RdsStack',  {
    
    /* If you don't specify 'env', this stack will be environment-agnostic.
    * Account/Region-dependent features and context lookups will not work,
    * but a single synthesized template can be deployed anywhere. */
    
    /* Uncomment the next line to specialize this stack for the AWS Account
    * and Region that are implied by the current CLI configuration. */
    
    // env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
    
    /* Uncomment the next line if you know exactly what Account and Region you
    * want to deploy the stack to. */
    // env: { account: '123456789012', region: 'us-east-1' },
    
    /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */
    });
    
  • to this:

    // bin/wordpress-ec2-rds.ts
    import  'source-map-support/register';
    import  *  as  cdk  from  '@aws-cdk/core';
    import  {  WordpressEc2RdsStack  }  from  '../lib/wordpress-ec2-rds-stack';
    import  {  config  }  from  '../lib/config';
    
    const  app  =  new  cdk.App();
    
    new  WordpressEc2RdsStack(app,  'WordpressEc2RdsStack',  {
        env:  config.env,
        description:  'Deploys resources for RDS and S3 powered Wordpress infrastructure',
        tags:  {  Project:  config.projectName,  Deployedby:  config.deployedBy  }
    });
    
  • Note: It's very important to let the CDK know what account and region you will deploy the stack to. This is why we explicitly set the env object (which contains account and region from your environment variables)

Our configuration files and entry files are nopw mostly set up. Let's get to creating our construct files.

VPC

In short, a VPC (Virtual Private Cloud) is virtual local network (VLAN) that you create on your cloud service provider. You can then add resources to this network and rest assured this network is separated (isolated) from other people's resources on the cloud provider. This has many advantages, the biggest one being security.

A VPC, essentially being a virtual LAN looks like this: 172.25.0.0/16. To understand what I just wrote, its important to understand:

Additionally, here is a great explanation from Amazon on VPCs.

Setup

  • mkdir -p lib/constructs/vpc.ts
  • You can either create a new VPC or import an existing one.
  • Since I do not have a VPC with isolated subnets, I will create a new VPC
  • We need to install the ec2 cdk module to get access to VPC creation functions

    npm install @aws-cdk/aws-ec2
    
  • Here we create the new VPC

    // lib/constructs/vpc.ts
    
    import * as cdk from '@aws-cdk/core'
    import * as ec2 from '@aws-cdk/aws-ec2'
    
    interface StackProps {
      prefix: string
      cidr: string
    }
    
    /**
     * Creates a new custom VPC
     *
     * @param  {cdk.Construct} scope stack application scope
     * @param  {StackProps} props props needed to create the resource
     *
     */
    export class CustomVPC {
      // we export the vpc we just created so other resources can use it
      public readonly vpc: ec2.IVpc
    
      constructor(scope: cdk.Construct, props: StackProps) {
        this.vpc = new ec2.Vpc(scope, `${props.prefix}-vpc`, {
          maxAzs: 2, // RDS requires at least 2 availability zones
          cidr: props.cidr, // the ip address block of the vpc e.g. '172.22.0.0/16'
          enableDnsHostnames: true,
          enableDnsSupport: true,
          // expensive -- we don't need that yet (we have no PRIVATE subnets)
          natGateways: 0, 
          subnetConfiguration: [
            {
              cidrMask: 22,
              name: `${props.prefix}-public-`,
              subnetType: ec2.SubnetType.PUBLIC, // for WP instance
            },
            {
              cidrMask: 22,
              name: `${props.prefix}-isolated-`,
              subnetType: ec2.SubnetType.ISOLATED, // for RDS DB
            },
          ],
        })
      }
    }
    
  • Here it's important to note that scope represents the base infrastructure class constructor that the new DefaultVPC instance will be created in. It comes from lib/wordpress-ec2-rds-stack.ts

  • Thus let's fetch our custom VPC inside the lib/wordpress-ec2-rds-stack.ts contructor.

  • Change lib/wordpress-ec2-rds-stack.ts from:

    // lib/wordpress-ec2-rds-stack.ts
    
    import  *  as  cdk  from  '@aws-cdk/core';
    
    export  class  WordpressEc2RdsStack  extends  cdk.Stack  {
        constructor(scope:  cdk.Construct,  id:  string,  props?:  cdk.StackProps)  {
            super(scope,  id,  props);
            // The code that defines your stack goes here
    
        }
    }
    
  • to this:

    // lib/wordpress-ec2-rds-stack.ts
    
    import  *  as  cdk  from  '@aws-cdk/core';
    import  {  CustomVPC  }  from  './constructs/vpc'
    
    export  class  WordpressEc2RdsStack  extends  cdk.Stack  {
        constructor(scope:  cdk.Construct,  id:  string,  props?:  cdk.StackProps)  {
            super(scope,  id,  props);
            // The code that defines your stack goes here
    
            // VPC -- fetch the custom VPC
            const  customVPC  =  new  CustomVPC(this,  {
                prefix:  config.projectName,
                cidr:  '172.22.0.0/16',
            })
        }
    }
    
  • If you're not sure about what all these configurations mean, you can read more about configuring VPCs in AWS in this tutorial

  • The important takeaway for this tutorial is that your custom VPC has both PUBLIC and ISOLATED (or PRIVATE) subnets

RDS

From the horse's mouth:

Amazon Relational Database Service (Amazon RDS) makes it easy to set up, operate, and scale a relational database in the cloud. It provides cost-efficient and resizable capacity while automating time-consuming administration tasks such as hardware provisioning, database setup, patching and backups. It frees you to focus on your applications so you can give them the fast performance, high availability, security and compatibility they need.

Amazon RDS is available on several database instance types - optimized for memory, performance or I/O - and provides you with six familiar database engines to choose from, including Amazon Aurora, PostgreSQL, MySQL, MariaDB, Oracle Database, and SQL Server. You can use the AWS Database Migration Service to easily migrate or replicate your existing databases to Amazon RDS.

In the last tutorial, we installed and ran the MySQL database on the same instance running Wordpress. You usually want to have your database on a separate (and managed) instance so that you can easily configure it, scale or resize it, back it up, secure it behind a private subnet etc AWS RDS is thus perfect for our use case

Setup

  • Create the cdk construct file

    mkdir -p lib/constructs/rds.ts
    
  • Let's install the cdk modules that we need

    npm install @aws-cdk/aws-rds @aws-cdk/aws-secretsmanager
    
  • Let's import what we need

    // lib/constructs/rds.ts
    
    import * as cdk from '@aws-cdk/core'
    import * as ec2 from '@aws-cdk/aws-ec2'
    import * as rds from '@aws-cdk/aws-rds'
    
    // this is where we will keep our database credentials e.g. user, password, host etc
    import * as secrets from '@aws-cdk/aws-secretsmanager'
    
  • Since the RDS construct is initialized from the base infrastructure class, when we create a new instance we can pass some needed properties

  • For example, we need to know the vpc we want to use, the database port or user we would like to use, the name of the secret in secretsmanager that will keep our db secrets etc

    interface StackProps {
      // this is useful when deploying to multiple environments e.g. prod, dev
      prefix: string
      vpc: ec2.Vpc
      user: string
      port: number
      database: string
      secretName: string
    }
    
    /**
     * Creates a MySQL DB on AWS RDS
     * 
     * @param  {cdk.Construct} scope stack application scope
     * @param  {StackProps} props props needed to create the resource
     * 
     */
    export class MySQLRdsInstance {
      public readonly databaseSecretName: string
    
      constructor(scope: cdk.Construct, props: StackProps){
        // this is where all the following code will go
      }
    }
    

Eveything that follows is inside the constructor of MySQLRdsInstance i.e. right below the // this is where all the following code will go comment:

  • Let's get our vpc from the props we just created

        // use the vpc we expoted from lib/constructs/vpc.ts
        const customVPC = props.vpc
    
  • Create a security group to allow connections only from inside the vpc

        // create the security group for RDS instance
        const ingressSecurityGroup = new ec2.SecurityGroup(scope, `${props.prefix}-rds-ingress`,{ 
            vpc: customVPC,
            securityGroupName: `${props.prefix}-rds-ingress-sg`,
          })
    
        ingressSecurityGroup.addIngressRule(
          // defaultVPC.vpcCidrBlock refers to all the IP addresses in defaultVPC
          ec2.Peer.ipv4(defaultVPC.vpcCidrBlock),
          ec2.Port.tcp(props.port || 3306),
          'Allows only local resources inside VPC to access this MySQL port (default -- 3306)'
        )
    
  • Generate the database secrets using Secrets Manager

        // Dynamically generate the username and password, then store in secrets manager
        const databaseCredentialsSecret = new secrets.Secret(
          scope,
         `${props.prefix}-MySQLCredentialsSecret`,
          {
            secretName: props.secretName,
            description: 'Credentials to access Wordpress MYSQL Database on RDS',
            generateSecretString: {
              secretStringTemplate: JSON.stringify({ username: props.user }),
              excludePunctuation: true,
              includeSpace: false,
              generateStringKey: 'password',
            },
          }
        )
    
  • Now let's create our RDS MySQL instance

        const mysqlRDSInstance = new rds.DatabaseInstance(scope, `${props.prefix}-MySqlRDSInstance`, {
          credentials: rds.Credentials.fromSecret(databaseCredentialsSecret),
          engine: rds.DatabaseInstanceEngine.mysql({
            version: rds.MysqlEngineVersion.VER_5_7_31
          }),
          port: props.port,
          allocatedStorage: 100,
          storageType: rds.StorageType.GP2,
          backupRetention: cdk.Duration.days(7),
          // t2.micro is free tier so we use it  
          instanceType: ec2.InstanceType.of(
            ec2.InstanceClass.T2,
            ec2.InstanceSize.MICRO
          ),
          vpc: customVPC,
          // we chose to place our database in an isolated subnet of our VPC
          vpcSubnets: { subnetType: ec2.SubnetType.ISOLATED },
          // if we destroy our database, AWS will take a snapshot of the database instance before terminating it
          removalPolicy: cdk.RemovalPolicy.SNAPSHOT,
          // accidental deletion protection -- you need to manually disable this in AWS web console to delete the database
          deletionProtection: true,
          securityGroups: [ingressSecurityGroup],
        })
    
  • View the final file here

  • Then let's import the RDS construct in our lib/wordpress-ec2-rds-stack.ts file:

        // lib/wordpress-ec2-rds-stack.ts
        import * as cdk from '@aws-cdk/core';
        import { DefaultVPC } from './constructs/vpc'
        import { MySQLRdsInstance } from './constructs/rds'
        import { config } from './config'
    
        export class WordpressEc2RdsStack extends cdk.Stack {
            constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
                super(scope, id, props);
    
                // The code that defines your stack goes here
    
                // VPC -- fetch the default VPC
                const defaultVPC = new DefaultVPC(this)
    
                // RDS -- create the mysql database
                new MySQLRdsInstance(this, {
                    prefix: config.projectName,
                    vpc: defaultVPC.vpc, // deploy the database in the default VPC!
                    user: 'wordpress_admin',
                    database: 'awesome-wp-site-db',
                    port: 3306,
                    // DB credentials will be saved under this pathname in AWS Secrets Manager
                    secretName: `${config.projectName}/rds/mysql/credentials`, // secret pathname
                })
            }
        }
    

Application Load Balancer

From the horse's mouth:

Elastic Load Balancing automatically distributes your incoming traffic across multiple targets, such as EC2 instances, containers, and IP addresses, in one or more Availability Zones. It monitors the health of its registered targets, and routes traffic only to the healthy targets. Elastic Load Balancing scales your load balancer as your incoming traffic changes over time. It can automatically scale to the vast majority of workloads.
Elastic Load Balancing supports the following load balancers: Application Load Balancers (ALBs), Network Load Balancers (NLBs), Gateway Load Balancers (GLBs), and Classic Load Balancers (CLBs).

Important Load Balancer concepts

  • A load balancer serves as the single point of contact for clients. The load balancer distributes incoming application traffic across multiple targets, such as EC2 instances, in multiple Availability Zones. This increases the availability of your application. You add one or more listeners to your load balancer.
  • A listener checks for connection requests from clients, using the protocol and port that you configure. The rules that you define for a listener determine how the load balancer routes requests to its registered targets. Each rule consists of a priority, one or more actions, and one or more conditions. When the conditions for a rule are met, then its actions are performed. You must define a default rule for each listener, and you can optionally define additional rules.
  • Each target group routes requests to one or more registered targets, such as EC2 instances, using the protocol and port number that you specify. You can register a target with multiple target groups. You can configure health checks on a per target group basis. Health checks are performed on all targets registered to a target group that is specified in a listener rule for your load balancer.

Setup

  • For this tutorial we will be setting up an ALB.
  • You can read about the difference between ALB, NLB and CLB here
  • Create a new file to define the ALB in and install required CDK dependency

    touch lib/constructs/alb.ts
    npm install @aws-cdk/aws-elasticloadbalancingv2
    
  • In the newly created file:

// lib/constructs/alb.ts

import * as cdk from '@aws-cdk/core'
import * as ec2 from '@aws-cdk/aws-ec2'
import * as elbv2 from '@aws-cdk/aws-elasticloadbalancingv2'

interface StackProps {
  prefix: string
  vpc: ec2.IVpc
}

/**
 * Creates an Application Load Balancer for our Wordpress stack
 *
 * @param  {cdk.Construct} scope stack application scope
 * @param  {StackProps} props props needed to create the resource
 *
 */
export class WordpressApplicationLoadBalancer {
  // export the DNS name of the load balancer for WP install 
  public readonly loadBalancerDnsName: string
  // export ALB listener so we can attach autoscaling group
  listener: elbv2.IApplicationListener

  constructor(scope: cdk.Construct, props: StackProps) {
    const alb = new elbv2.ApplicationLoadBalancer(
      scope,
      `${props.prefix}-alb`,
      {
        loadBalancerName: `${props.prefix}-alb`,
        vpc: props.vpc,
        internetFacing: true,
      }
    )

    // we need to expose the dns name of the load balancer
    // so we can use it when installing Wordpress later
    this.loadBalancerDnsName = alb.loadBalancerDnsName

    // we will  need the listener to add our autoscaling group later
    this.listener = alb.addListener(`${props.prefix}-alb-listener`, {
      port: 80,
      open: true,
    })

    // print out the dns name of the alb
    new cdk.CfnOutput(scope, `${props.prefix}-alb-dns-name`, {
      value: alb.loadBalancerDnsName,
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

Then let’s import the ALB construct in our lib/wordpress-ec2-rds-stack.ts file:

// lib/wordpress-ec2-rds-stack.ts
import * as cdk from '@aws-cdk/core';
import { DefaultVPC } from './constructs/vpc'
import { MySQLRdsInstance } from './constructs/rds'
import { WordpressApplicationLoadBalancer } from './constructs/alb'
import { config } from './config'

export class WordpressEc2RdsStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // The code that defines your stack goes here

    // VPC -- fetch the default VPC
    const defaultVPC = new DefaultVPC(this)

    // RDS -- create the mysql database
    new MySQLRdsInstance(this, {
      prefix: config.projectName,
      vpc: defaultVPC.vpc, // deploy the database in the default VPC!
      user: 'wordpress_admin',
      database: 'awesome-wp-site-db',
      port: 3306,
      // DB credentials will be saved under this pathname in AWS Secrets Manager
      secretName: `${config.projectName}/rds/mysql/credentials`, // secret pathname
    })

    // ALB -- for our single instance
    const { loadBalancerDnsName, listener } = new WordpressApplicationLoadBalancer(this, {
      prefix: config.projectName,
      vpc: customVPC.vpc,
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

EC2 via Autoscaling Group

In the last tutorial, we used the CDK to launch a single EC2 instance. This time, we will do the same thing, however, we will launch the instance using an ASG.

From the horse's mouth:

An Auto Scaling group contains a collection of Amazon EC2 instances that are treated as a logical grouping for the purposes of automatic scaling and management. An Auto Scaling group also enables you to use Amazon EC2 Auto Scaling features such as health check replacements and scaling policies. Both maintaining the number of instances in an Auto Scaling group and automatic scaling are the core functionality of the Amazon EC2 Auto Scaling service.

The size of an Auto Scaling group depends on the number of instances that you set as the desired capacity. You can adjust its size to meet demand, either manually or by using automatic scaling.

An Auto Scaling group starts by launching enough instances to meet its desired capacity. It maintains this number of instances by performing periodic health checks on the instances in the group. The Auto Scaling group continues to maintain a fixed number of instances even if an instance becomes unhealthy. If an instance becomes unhealthy, the group terminates the unhealthy instance and launches another instance to replace it. For more information, see Health checks for Auto Scaling instances.

  • In our use case, we will use just one instance. This is because Wordpress is installed to the volume attached to this singular instance. If we launched more than two instances, then we would be redirecting users to two seperate WP installations (that may end up having different uploaded content, plugins etc because these files are installed to the file volumes).
  • This is desirable in certain cases e.g. if you want to serve users in different geographic regions different website content (without changing the url).
  • For most users however, the main use case of autoscaling is to spread server load horizonatally across instances.
  • A work-around that I did not implement in this tutorial is to sync uploaded content to S3 and use a shared EFS file system for the wordpress installation (something which I may look at in detail in another tutorial or at the ednof this one)
  • What then is the purpose of using an ASG with just one desired instance? Good question. For this tutorial, it is mostly used to warm you up to the concept of using ASGs and Load Balancers. In later tutorials, I will write applications that are better at utilizing ASGs than Wordpress. Also, if you instance should get accidentally terminated somehow, the ASG will immediately spin up another one. Awesome!

Setup

  • Create the file to hold our EC2 constructs and install dependecies

    touch lib/constructs/ec2/ts
    npm install @aws-cdk/aws-secretsmanager @aws-cdk/aws-autoscaling
    
  • This time, instead of using an SSH key, we will use aws-ssm and IAM to access our instances via SSH

  • We will place our instance in an autoscaling group with a maximum and minimum capacity of 1

  • We are also going to define a helper function to help do some string replacement later on. We will be using this function to insert envrionment secrets into our user script.

  • touch lib/utils.ts

    // lib/utils.ts
    
    /**
     * Replaces all given substring in a text with new substring e.g.
     *
     * @example
     *  const text = 'The woman and man and woman and man'
     *  const wordsArray = [{ man: 'boy' }, { woman: 'girl' }]
     *
     *  console.log(replaceAllSubstrings(wordsArray, text))
     *  // The girl and boy and girl and boy
     *
     * @param  {Array<Record<string, string>>} wordsArray the words to be substituted and the words to substitute them with
     * @param  {string} text the text from which to substitute the given sub strings
     * @returns the altered text
     */
    export const replaceAllSubstrings = (
      wordsArray: Array<Record<string, string>>,
      text: string
    ) =>
      wordsArray.reduce(
        (f, s) =>
          `${f}`.replace(new RegExp(Object.keys(s)[0], 'g'), s[Object.keys(s)[0]]),
        text
      )
    
  • Inside lib/constructs/ec2.ts, we start by getting our imports

    import  *  as  fs  from  'fs'
    import  *  as  cdk  from  '@aws-cdk/core'
    import  *  as  ec2  from  '@aws-cdk/aws-ec2'
    import  *  as  secrets  from  '@aws-cdk/aws-secretsmanager'
    import  *  as  autoscaling  from  '@aws-cdk/aws-autoscaling'
    import  *  as  iam  from  '@aws-cdk/aws-iam'
    import  {  config  }  from  '../config'
    import  {  replaceAllSubstrings  }  from  '../utils'
    
  • We then define the props for the EC2 instance class we are defining

    interface  StackProps  {
        prefix:  string
        vpc:  ec2.IVpc
        /* the dns name of the ALB */
        dnsName:  string
        /* the path of the db access secret in AWS SM */
        dbSecretName: string 
        /* the path of the wp admin secret in AWS SM */
        wpSecretName: string
    }
    
  • We then define a new class to hold our construct code

    /**
     * Creates the Wordpress EC2 AutoscalingGroup
     *
     * @param  {cdk.Construct} scope stack application scope
     * @param  {StackProps} props props needed to create the resource
     *
     */
    export class WordpressAutoScalingGroup {
      // export our newly created instance
      public readonly asg: autoscaling.AutoScalingGroup
    
      constructor(scope: cdk.Construct, props: StackProps) {
        // all the code will go here
      }
    }
    
  • Inside the constructor, we can start by importing the custom vpc, defining a role for ur ec2 instance and creating the necessary security groups:

        // use the vpc we just created
        const customVPC = props.vpc
    
        // define a role for the wordpress instances
        const role = new iam.Role(scope, `${props.prefix}-instance-role`, {
          assumedBy: new iam.CompositePrincipal(
            new iam.ServicePrincipal('ec2.amazonaws.com'),
            new iam.ServicePrincipal('ssm.amazonaws.com')
          ),
          managedPolicies: [
            // allows us to access instance via SSH using IAM and SSM
            iam.ManagedPolicy.fromAwsManagedPolicyName(
              'AmazonSSMManagedInstanceCore'
            ),
            // allows ec2 instance to access secrets maanger and retrieve secrets
            iam.ManagedPolicy.fromAwsManagedPolicyName('SecretsManagerReadWrite'),
          ],
        })
    
        // lets create a security group for the wordpress instance
        const securityGroup = new ec2.SecurityGroup(
          scope,
          'wordpress-instances-sg',
          {
            vpc: customVPC,
            allowAllOutbound: true,
            securityGroupName: 'wordpress-instances-sg',
          }
        )
        // NB: the WP instance will not be exposed to the public Internet this time
        // the Internet can access it through the ALB only
        // the admin can access it (the console) via SSM
        securityGroup.addIngressRule(
          ec2.Peer.ipv4(customVPC.vpcCidrBlock),
          ec2.Port.tcp(80),
          'Allows HTTP access from resources inside our VPC (like the ALB)'
        )
    
  • Since we want to automate the entire installation of WP on our instance, we will need to automate the famous Wordpress 5 minute install (See step 9 in this article).

  • In order to do this, we need to define secrets for our WP Admin

  • Inside lib/config.ts, we can add a new object wordpress to keep some of those secrets:

    // lib/config.ts
    ...
    ...
    export const config = {
      projectName: `wordpress-ec2-rds-${stage}`, 
      ...
      wordpress: {
        admin: {
          username: process.env.WP_ADMIN_USER || 'admin',
          email: process.env.WP_ADMIN_EMAIL || 'admin@whatever.com'
        },
        site: {
          // name of the database WP will use (cannot have hyphens) 
          databaseName: process.env.WP_DB_NAME || 'awesome_wp_site_db',
          // the name of our WP  website
          title: process.env.WP_SITE_TITLE || 'awesome-wp-site',
          // where we will install WP on our instance volume
          installPath: process.env.WP_SITE_INSTALL_PATH || '/var/www/html/',
        }
      }
    }
    
  • Make sure you add those variables in your .env file:

    STAGE=dev
    AWS_ACCOUNT_NUMBER=XXXXXXXXXXXXX
    AWS_REGION=us-west-2
    DEPLOYED_BY=john.doe
    
    # Wordpress Site Variables
    WP_DB_NAME='awesome_wp_site_db'
    WP_SITE_TITLE='awesome_wp_site'
    WP_SITE_INSTALL_PATH=/var/www/html/
    
    # Wordpress Admin Secrets
    WP_ADMIN_EMAIL=admin@awesomewpsite.com
    WP_ADMIN_USER=admin
    
  • Let's add the code to add our WP admin secrets in AWS SM for Wordpress Admin as well as generate a new WP Admin password inside the WordpressAutoScalingGroup constructor

        ...
        ...
    
        // secrets for wp admin
        new secrets.Secret(scope, 'WordpressAdminSecrets', {
          secretName: props.wpSecretName,
          description: 'Admin credentials to access Wordpress',
          generateSecretString: {
            secretStringTemplate: JSON.stringify({
              username: config.wordpress.admin.username,
              email: config.wordpress.admin.email,
            }),
            // will generate a random password under the object key 'password'
            generateStringKey: 'password',
          },
        })
    

User script

  • We now need to write our user script to configure Wordpress on our instance and automate the famous 5 minute WP install
  • In summary, we need to first install the dependencies (e.g. Apache, PHP and Wordpress etc) on the instance
  • Then we need to wait for the RDS MySQL database to initialize and be in a ready state (very important). If the database is not ready, then WP installation will fail because you won't be able to create the necessary tables in the database.
  • When the database is ready, we install Wordpress and that's it!
  • Read the comments in the script to understand what's going on

    mkdir lib/scripts && touch lib/scripts/wordpress_install.sh
    
```bash
#! /bin/bash

# lib/scripts/wordpress_install.sh

#------------------ 0.USEFUL FUNCTIONS

# Checks to see if an env is defined (not null) in the bash session
is_defined () {
    for var in "$@" ; do
        if [ ! -z "${!var}" ] & [ "${!var}" != "null" ]; then
            echo "$var is set to ${!var}"
        else
            echo "$var is not set"
            return 1
        fi
    done
}

# Checks if desired db secrets in secrets manager are ready
# Db secrets are only fully ready when the RDS DB is ready
db_secrets_ready () {
    if ! is_defined "AWS_REGION" "DB_SECRETS_PATH";then
        return 0
    fi

    echo "Retrieving secrets..." 
    DB_SECRETS_JSON=$(aws secretsmanager get-secret-value --secret-id $DB_SECRETS_PATH --region $AWS_REGION | jq -r '.SecretString')

    echo "Retrieved secrets." 
    DB_USER=$(echo $DB_SECRETS_JSON | jq -r '.username')
    DB_PASS=$(echo $DB_SECRETS_JSON | jq -r '.password')
    DB_HOST=$(echo $DB_SECRETS_JSON | jq -r '.host')
    DB_PORT=$(echo $DB_SECRETS_JSON | jq -r '.port')

    echo "Checking secrets..." 
    if ! is_defined "DB_USER" "DB_PASS" "DB_HOST" "DB_PORT";then
        echo "Secrets are not ready." 
        return 1
    fi

    echo "Secrets are ready." 
    return 0

}

#------------------  1.INSTALL DEPENDECIES
# update dependencies
yum -y update

# Install Apache
yum -y install httpd

# Start Apache
service httpd start

# Install PHP, PHP CLI, JQ, MySQL
yum -y install php php-cli php-mysql jq mysql mysqladmin

# PHP7 needed for latest wordpress
amazon-linux-extras install php7.4 -y 

# Restart Apache
service httpd restart

# Install the Wordpress CLI which will help us install Wordpress correctly
curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod +x wp-cli.phar
mv wp-cli.phar /usr/local/bin/wp

#------------------  2.SET SCRIPT GLOBAL VARIABLES

# AWS and Wordpress variables to replace
# We will replace these variables in the CDK ec2 construct file
# before using the script to launch an ec2 instance
DB_SECRETS_PATH=_DB_SECRETS_PATH_
WP_SECRETS_PATH=_WP_SECRETS_PATH_
AWS_REGION=_AWS_REGION_
WP_DB_NAME=_WP_DB_NAME_
WP_SITE_TITLE=_WP_SITE_TITLE_
WP_SITE_INSTALL_PATH=_WP_SITE_INSTALL_PATH_
WP_SITE_BASE_DOMAIN=_WP_SITE_BASE_DOMAIN_

# Wait for Secrets Manager to have RDS secret ready
# Certain database secrets (e.g host, port) won't be ready until the database is ready
echo "Waiting up to 20 minutes for Secrets Manager to be ready with Secrets";
for i in {1..240}; do
    echo "try count: $i"
    db_secrets_ready && break;
    # retry every 30 seconds
    sleep 30s; 
done
echo "Secrets Manager is ready with Secrets";

# Use the AWS CLI to get secrets from Secrets Manager
DB_SECRETS_JSON=$(aws secretsmanager get-secret-value --secret-id $DB_SECRETS_PATH --region $AWS_REGION | jq -r '.SecretString')
WP_SECRETS_JSON=$(aws secretsmanager get-secret-value --secret-id $WP_SECRETS_PATH --region $AWS_REGION | jq -r '.SecretString')

# Parse secrets from JSON response using the useful jq
DB_USER=$(echo $DB_SECRETS_JSON | jq -r '.username')
DB_PASS=$(echo $DB_SECRETS_JSON | jq -r '.password')
DB_HOST=$(echo $DB_SECRETS_JSON | jq -r '.host')
DB_PORT=$(echo $DB_SECRETS_JSON | jq -r '.port')
WP_ADMIN_USER=$(echo $WP_SECRETS_JSON | jq -r '.username')
WP_ADMIN_PASSWORD=$(echo $WP_SECRETS_JSON | jq -r '.password')
WP_ADMIN_EMAIL=$(echo $WP_SECRETS_JSON | jq -r '.email')

# If some ENV is not defined, stop the script
if ! is_defined \
"DB_SECRETS_PATH" \
"WP_SECRETS_PATH" \
"AWS_REGION" \
"WP_DB_NAME" \
"WP_SITE_TITLE" \
"WP_SITE_INSTALL_PATH" \
"WP_SITE_BASE_DOMAIN" \
"DB_USER" \
"DB_PASS" \
"DB_HOST" \
"DB_PORT" \
"WP_ADMIN_USER" \
"WP_ADMIN_PASSWORD" \
"WP_ADMIN_EMAIL" \
; then
    echo "Exiting WP installation script because some variables were undefined"
    exit 0
fi

#------------------  3.CREATE WORDPRESS MYSQL DATABASE

# Wait for the database to be ready
# Usually this should only run once because of the DB secrets in AWS SM are ready
# then it means the database is likely ready as well
for i in {1..30}; do
    echo "try count: $i"
    mysqladmin ping -h "$DB_HOST" -u$DB_USER -p$DB_PASS -P $DB_PORT --silent && break;
    # retry every 30s
    sleep 30s
done

# Create the database.
echo "Creating the database $WP_DB_NAME..."
mysql -h $DB_HOST -u$DB_USER -p$DB_PASS -P $DB_PORT -e"CREATE DATABASE $WP_DB_NAME"

#------------------  4.SETUP WORDPRESS INSTALLATION

# Download WP Core.
/usr/local/bin/wp core download --path=$WP_SITE_INSTALL_PATH

# Generate the wp-config.php file
/usr/local/bin/wp core config \
--path=$WP_SITE_INSTALL_PATH \
--dbname=$WP_DB_NAME \
--dbuser=$DB_USER \
--dbpass=$DB_PASS \
--dbhost=$DB_HOST \
--extra-php <<PHP
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', true);
define('WP_MEMORY_LIMIT', '256M');
PHP

# Install the WordPress database.
/usr/local/bin/wp core install \
--path=$WP_SITE_INSTALL_PATH \
--url=$WP_SITE_BASE_DOMAIN \
--title=$WP_SITE_TITLE \
--admin_user=$WP_ADMIN_USER \
--admin_password=$WP_ADMIN_PASSWORD \
--admin_email=$WP_ADMIN_EMAIL

# Restart Apache
service httpd restart

# Wordpress is now installed!
```
Enter fullscreen mode Exit fullscreen mode
  • If all goes well, when yuo navigate to the loadbalancer DNS name, the wordpress website should just pop up without any need for any configuration
  • Let's now add the user script and insert some required environment variables in the CDK file
  • In the WordpressAutoScalingGroup constructor

        ...
        ...
    
        // Fetch the user script from file system as a string
        const userScript = fs.readFileSync(
          'lib/scripts/wordpress_install.sh',
          'utf8'
        )
    
        // Replace the following variable substrings in the userScript
        const modifiedUserScript = replaceAllSubstrings(
          [
            { _DB_SECRETS_PATH_: props.dbSecretName },
            { _WP_SECRETS_PATH_: props.wpSecretName },
            { _AWS_REGION_: config.env.region },
            { _WP_DB_NAME_: config.wordpress.site.databaseName },
            { _WP_SITE_TITLE_: config.wordpress.site.title },
            { _WP_SITE_INSTALL_PATH_: config.wordpress.site.installPath },
            { _WP_SITE_BASE_DOMAIN_: props.dnsName }, // our load balancer dns name
          ],
          userScript
        )
    

EC2 instance in ASG

  • Finally, we can create our ec2 instance in an ASG

    ...
    ...
    // finally create and export out autoscaling group
    this.asg = new autoscaling.AutoScalingGroup(scope, `${props.prefix}-asg`, {
      vpc: customVPC,
      // add the role we created (needs access to AWS SM)
      role,
      instanceType: ec2.InstanceType.of(
        ec2.InstanceClass.T2,
        ec2.InstanceSize.MICRO
      ),
      machineImage: ec2.MachineImage.latestAmazonLinux({
        generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2,
      }),
      // add our modified user script to launch with instances in this ASG
      userData: ec2.UserData.custom(modifiedUserScript),
      // we only want one instance in our ASG
      minCapacity: 1,
      maxCapacity: 1,
      associatePublicIpAddress: true,
      vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
    })
    
  • The final file should look like this

  • We then need to attach the ASG we just created as a target to the the listener of the ALB we created earlier, using port 80

  • This will redirect all requests for WP at the loadblancer to port 80 of our WP instance.

  • Right below the EC2 construct in the WordpressEc2RdsStack constructor, add:

        // lets add our autoscaling group to our load balancer
        listener.addTargets(`${config.projectName}-wp-asg-targets`, {
          port: 80,
          targets: [asg]
        }) 
    
  • The final lib/wordpress-ec2-rds-stack.ts should look like:

    import * as cdk from '@aws-cdk/core'
    import { CustomVPC } from './constructs/vpc'
    import { MySQLRdsInstance } from './constructs/rds'
    import { WordpressAutoScalingGroup } from './constructs/ec2'
    import { WordpressApplicationLoadBalancer } from './constructs/alb'
    import { config } from './config'
    
    export class WordpressEc2RdsStack extends cdk.Stack {
      constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
        super(scope, id, props)
        // The code that defines your stack goes here
    
        // VPC -- fetch the custom VPC
        const customVPC = new CustomVPC(this, {
          prefix: config.projectName,
          cidr: '172.22.0.0/16',
        })
    
        // RDS -- create the mysql database
        new MySQLRdsInstance(this, {
          prefix: config.projectName,
          vpc: customVPC.vpc,
          user: 'wordpress_admin',
          database: 'awesome-wp-site-db',
          port: 3306,
          // DB credentials will be saved under this pathname in AWS Secrets Manager
          secretName: `${config.projectName}/rds/mysql/credentials`, // secret pathname
        })
    
        // Application Loadbalancer -- for our single instance
        const { loadBalancerDnsName, listener } = new WordpressApplicationLoadBalancer(this, {
          prefix: config.projectName,
          vpc: customVPC.vpc,
        })
    
        // EC2 -- create the Wordpress instance in an autoscaling group
        const { asg } = new WordpressAutoScalingGroup(this, {
          prefix: config.projectName,
          vpc: customVPC.vpc,
          dnsName: loadBalancerDnsName,
          dbSecretName: `${config.projectName}/rds/mysql/credentials`,
          wpSecretName: `${config.projectName}/wordpress/admin/credentials`,
        })
    
        // lets add our autoscaling group to our load balancer
        listener.addTargets(`${config.projectName}-wp-asg-targets`, {
          port: 80,
          targets: [asg]
        })
      }
    }
    
  • At this point, all the resources are ready to be deployed!

    Deployment

    Local machine

  • To deploy from your local machine, make sure all your aws credentials and configurations are correctly setup

  • Ensure you have the .env file with the following environment variables setup:

    STAGE=dev
    AWS_ACCOUNT_NUMBER=XXXXXXXXXXXXXXX
    AWS_REGION=us-west-2
    DEPLOYED_BY=john.doe
    
    # Wordpress Variables
    WP_DB_NAME='awesome_wp_site_db'
    WP_SITE_TITLE='awesome_wp_site'
    WP_SITE_INSTALL_PATH=/var/www/html/
    
    # Wordpress Admin Secrets
    WP_ADMIN_EMAIL=admin@awesomewpsite.com
    WP_ADMIN_USER=admin
    
  • You can then first try to synthesize the stack and make sure everything is correct

    cdk synth --profile default
    
  • If the CDK outputs a YAML file to the console without any issue, then you can deploy

    cdk deploy --profile default
    
  • You will be asked to approve of IAM changes before the deployment can continue

Github Actions

  • Deploying from your local machine is not recommended in a production environment.
  • Fortunately, Github provides a free CI/CD service called Github Actions
  • All we have to do in this instance is create a simple YAML configuration file that can help us deploy to different stages using GA (for the purposes of this tutorial, just dev and prod)
  • From the root of your project

    mkdir -p .github/workflows && touch .github/workflows/deploy.yml
    
  • Open up .github/workflows/deploy.yml and add the following code

    name: Deploy
    
    on:
      push:
        branches:
          - master
          - dev
    jobs:
      deploy:
        runs-on: ubuntu-latest
        steps:
          - name: Checkout repository
            uses: actions/checkout@v2
          - uses: nelonoel/branch-name@v1.0.1
          - name: cdk deploy
            uses: youyo/aws-cdk-github-actions@v1
            with:
              cdk_subcommand: 'deploy'
              cdk_args: '--require-approval never'
            env:
              STAGE: ${{ env.BRANCH_NAME == 'master' && 'prod' || 'dev'  }}
              AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
              AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
              AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }}
              AWS_ACCOUNT_NUMBER: ${{ secrets.AWS_ACCOUNT_NUMBER }}
              DEPLOYED_BY: ${{ secrets.DEPLOYED_BY }}
    
  • What's going on?

    • GA will only run this script when a push/merge to the branches dev or master happens
    • GA will create an Ubuntu container, checkout the repository and run CDK deploy
    • A useful GA action called branch-name@v1.0.1 gets the name fo the branch we are working on and deploys to the correct stage based on that branch (it sets the STAGE env variable)
    • GA action aws-cdk-github-actions@v1 runs cdk deploy and deploys the resources to AWS
  • In order for this to work, you have to to go the settings of your repository on Github and add the follwowing secrets:

    • AWS_ACCESS_KEY_ID
    • AWS_SECRET_ACCESS_KEY
    • AWS_DEFAULT_REGION
    • AWS_ACCOUNT_NUMBER
    • DEPLOYED_BY
  • For AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, you can create a new user in IAM in the AWS console called github.actions.bot that has administrator privileges and only PROGRAMMATIC ACCESS and paste those values in Github settings as secrets

Destroying the stack

  • To destroy the stack from your local machine using the CDK, just run

    cdk destroy --profile default
    
  • To destroy the stack from AWS console

    • Log in to the AWS console
    • Go to Cloudformation
    • Find your stack > Destroy/Delete

Final result

Whether you deploy from your local machine or via Github actions, if all goes well, after about 20 minutes (RDS databases take a while to initialize) you should see this output:

Alt Text

  • In your browser, navigate to the DNS name printed in the output of your local machine console or the GA console(replace the asteriks with the region you deployed in).
  • Alternatively, you can find the DNS name of the ALB via EC2 console > Load balancers
  • You should then see:

Alt Text

Conclusion

  • In this tutorial I tried to keep things simple and did not add many improvements that I would likely add in a real production scenario (See the Homework Assignments section for improvements that can make your WP installation Production ready on AWS)

Debugging

  • To access the ec2 instance your ASG just spn up, you need to get the instance id from the AWS EC2 console
  • Once you have the instance id, to log into your instance, just do:

    aws ssm start-session --target "i-0191364267ad972a2" --profile default
    
  • where i-0191364267ad972a2 is the id of the instance as seen in the AWS console. Cool right? No need for SSH keys!

  • Of course, if you still want to use regular SSH then in the CDK you have to open port 22 and create an SSH key pair on your machine and upload the public key to the ec2 instance.

  • If Wordpress does not load correctly when you navigate to the ALB DNS name, then it is likely something with the configuration of the database, or access to secrets manager went wrong.

  • You can check the user script logs in the ec2 instance

    # list all log files related to ec2 start up scripts
    find / -name "*cloud*log"
    

Homework Assignments

There are many improvements you can make to this CDK stack to make a better WP installation. I'll leave them as homework

  • Create an S3 bucket using the CDK and use this Wordpress plugin to sync Wordpress uploads to AWS S3. Hint: You install this plugin via the user script.
  • Use the CDK to create an EFS filesystem to install Wordpress to instead of installing Wordpress to var/www/html on the default attached volume (which limits WP to one instance). The key here is the same EFS filesystem can be used by many instances.
  • The above two improvements combined will allow you to use more than one instance in the WP ASG and make your Wordpress Installation much more highly available and resilient.
  • Create a cloudfront distribution to serve the WP content from the S3 bucket you just created
  • Buy a cheap 5 dollar .link domain on AWS Route53 and use it in the CDK instead of the ALB dnsName. That way, your site can be accessed via something like mywebsite.link
  • Add CloudWatchAgentServerPolicy to Wordpress instance role and in the user script, install Cloudwatch Agent so that you can collect logs and internal metrics from the instance in AWS Cloudwatch. See this tutorial.

Hi I'm Emmanuel! I write about Software and DevOps.

If you liked this article and want to see more, add me on LinkedIn or follow me on Twitter

Top comments (1)

Collapse
 
warrensadler profile image
Warren Sadler

One of the most comprehensive tutorials on the subject, thank you for this incredible resource!

Some comments may only be visible to logged-in visitors. Sign in to view all comments.