- Introduction
- Bootstrapping a new CDK Application
- Structure and Setup of a CDK application
- Writing the infrastructure code
- Creating a new Key Pair in the AWS Console
- Deploy
- Accessing the instance
- Updating the deployed stack
- Testing the stack
- Destroying the stack
- Notes
- Conclusion
- Up next
Introduction
Most tutorials on AWS try to get you to deploy the world famous t2.micro
instance (a small virtual server under the free usage tier on AWS) using the AWS console. You then go on to install something like Wordpress through SSH or through a user script (a script that runs when an instance is created) that you define in the AWS console.
So that's exactly what we're going to deploy. But we're going to use the AWS CDK instead.
This is what you'll learn in this tutorial:
- Bootstrapping a new CDK application
- The structure and setup of a CDK application
- How to provision and setup an ec2 instance
- How to setup terminal access to the instance via SSH key.
- How to update the ec2 instance with a user script and update the deployed cdk stack
- How to write tests for the cdk application
- How to destroy the deployed stack
Let's go!
Bootstrapping a new CDK Application
Using your terminal, create a new directory simple-ec2
and cd into it:
mkdir simple-ec2 && cd simple-ec2
We previously setup the AWS CDK cli globally. If you haven't done that see Part 2 in this series.
Bootstrap a new cdk project template that uses Typescript:
cdk init --language=typescript
This will initialize a new cdk project for you in Typescript.
- Run
npm update
to ensure you're using the latest version of the CDK. - If you have version conflicts between
@aws-cdk/core
and other@aws-cdk
sub-packages, then you'll come across some weird errors.@aws-cdk/core
and every other imported@aws-cdk/PACKAGE
should have the same version.
Structure and Setup of a CDK application
Structure
bash
# tree -I 'node_modules'
.
├── bin
│ └── simple-ec2.ts # entry point
├── cdk.json
├── jest.config.js # for tests
├── lib # where the infrastructure code you write will go
│ └── simple-ec2-stack.ts
├── package.json
├── package-lock.json
├── README.md
├── test # test folder
│ └── simple-ec2.test.ts
└── tsconfig.json
-
./bin/simple-ec2.ts
is the entry point file used by the cdk. This is where you define your stack(s). - The IaC that provisions the resources will be inside the
lib
folder and is required by./bin/simple-ec2.ts
duringsynth
anddeploy
actions. I'll explain both commands later. -
./test/simple-ec2.test.ts
contains the template code to test your CDK application
Setup
In ./bin/simple-ec2.ts
a new App()
is defined and this represents a single stack.
- We can add a description for our stack in this file.
- This description will be visible in the Cloudformation console: ```ts
// ./bin/simple-ec2.ts
import 'source-map-support/register';
import * as cdk from '@aws-cdk/core';
import { SimpleEc2Stack } from '../lib/simple-ec2-stack';
const app = new cdk.App();
new SimpleEc2Stack(app, 'SimpleEc2Stack', {
description: 'This is a simple EC2 stack'
});
Let's head over to `./lib/simple-ec2-stack.ts` and see what the CDK boostrapped for us:
```ts
// ./lib/simple-ec2-stack.ts
import * as cdk from '@aws-cdk/core';
export class SimpleEc2Stack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// The code that defines your stack goes here
}
}
As you can see, the cdk init
command bootstrapped a nice template for us to get to writing our IaC fast. The base class cdk.Stack
gives us the ability to create a new Cloudformation stack.
We also need to create a .env
file to keep our AWS Account number and the region we will use. In the root of the project create a file called .env
and add the following. Replace the xxXxXxxXXxXxx
with your AWS account number and use the region you want.
AWS_ACCOUNT_NUMBER=xxXxXxxXXxXxx
AWS_ACCOUNT_REGION=us-west-2
Writing the infrastructure code
We want to create an ec2 instance so we will need the @aws-cdk/ec2
library. (This is the same process for any other AWS service you need to provision resources). We will also need @aws-cdk/aws-iam
library to give permissions to our instance to do stuff. We also want to be able to read our .env
file so lets also install dotenv
package
npm install @aws-cdk/aws-ec2 @aws-cdk/aws-iam dotenv
Remember, since the CDK is written in Typescript and is typed excellently, while typing you can access intellisense and see the various properties of CDK resources e.g. in the image below I can see what properties an instance of ec2.Instance()
class has.
Here goes our first iteration:
import * as cdk from '@aws-cdk/core'
import * as ec2 from '@aws-cdk/aws-ec2' // import ec2 library
import * as iam from '@aws-cdk/aws-iam' // import iam library for permissions
require('dotenv').config()
const config = {
env: {
account: process.env.AWS_ACCOUNT_NUMBER,
region: process.env.AWS_REGION
}
}
export class SimpleEc2Stack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
// its important to add our env config here otherwise CDK won't know our AWS account number
super(scope, id, { ...props, env: config.env })
// Get the default VPC. This is the network where your instance will be provisioned
// All activated regions in AWS have a default vpc.
// You can create your own of course as well. https://aws.amazon.com/vpc/
const defaultVpc = ec2.Vpc.fromLookup(this, 'VPC', { isDefault: true })
// Lets create a role for the instance
// You can attach permissions to a role and determine what your
// instance can or can not do
const role = new iam.Role(
this,
'simple-instance-1-role', // this is a unique id that will represent this resource in a Cloudformation template
{ assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com') }
)
// lets create a security group for our instance
// A security group acts as a virtual firewall for your instance to control inbound and outbound traffic.
const securityGroup = new ec2.SecurityGroup(
this,
'simple-instance-1-sg',
{
vpc: defaultVpc,
allowAllOutbound: true, // will let your instance send outboud traffic
securityGroupName: 'simple-instance-1-sg',
}
)
// lets use the security group to allow inbound traffic on specific ports
securityGroup.addIngressRule(
ec2.Peer.anyIpv4(),
ec2.Port.tcp(22),
'Allows SSH access from Internet'
)
securityGroup.addIngressRule(
ec2.Peer.anyIpv4(),
ec2.Port.tcp(80),
'Allows HTTP access from Internet'
)
securityGroup.addIngressRule(
ec2.Peer.anyIpv4(),
ec2.Port.tcp(443),
'Allows HTTPS access from Internet'
)
// Finally lets provision our ec2 instance
const instance = new ec2.Instance(this, 'simple-instance-1', {
vpc: defaultVpc,
role: role,
securityGroup: securityGroup,
instanceName: 'simple-instance-1',
instanceType: ec2.InstanceType.of( // t2.micro has free tier usage in aws
ec2.InstanceClass.T2,
ec2.InstanceSize.MICRO
),
machineImage: ec2.MachineImage.latestAmazonLinux({
generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2,
}),
keyName: 'simple-instance-1-key', // we will create this in the console before we deploy
})
// cdk lets us output prperties of the resources we create after they are created
// we want the ip address of this new instance so we can ssh into it later
new cdk.CfnOutput(this, 'simple-instance-1-output', {
value: instance.instancePublicIp
})
}
}
Creating a new Key Pair in the AWS Console
Before we try to deploy our newly created instance, we need to go to the AWS console and create a key pair that we will use to access the instance called simple-instance-1-key
- Log into the AWS console.
- Go to
EC2
dashboard. - Go to Key Pairs and click create Key Pair
- Enter key name as
simple-instance-1-key
and click create - Your new key pair will be created and your browser will automatically download a new
.pem
file calledsimple-instance-1-key.pem
- this is the key file you'll use to gain access to your instance via SSH
- Create a new directory under
.aws/
calledpems
```bash
mkdir ~./aws/pems/
- move the newly downloaded file to this directory and give it the necessary permissions
```bash
mv ~/Downloads/simple-instance-1-key.pem ~/.aws/pems
# important step or your key file won't work
chmod 400 ~/.aws/pems/simple-instance-1-key.pem
- Now that we have our key file properly setup, lets deploy our instance!
Deploy
Remember we set up our aws profiles and crednetials in ~/.aws/config
and ~/.aws/credentials
back in part 2
I will be deploying to my default
profile which is linked to my personal AWS account with the region us-west-2
.
If you have another profile you want to use then in the commands below use that profile name instead of default
.
In your terminal:
cdk synth --profile default
This command will synthesize your stack.
When CDK apps are executed, they produce (or “synthesize”, in CDK parlance) an AWS CloudFormation template for each stack defined in your application.
- Essentially it will print the cloudformation template for your stack to your console.
- It's a good way to check that there's nothing wrong with your stack before trying to deploy since
cdk synth
will verify the resources you are trying to provision can actually be provisioned. - You should something like this:
❯ cdk synth --profile default
Resources:
simpleinstance1role9EEDA67C:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Action: sts:AssumeRole
Effect: Allow
Principal:
Service: ec2.amazonaws.com
Version: "2012-10-17"
...
...
...
If the entire stack prints without error then you're okay to go.
Now we can deploy:
cdk deploy --profile default
You should get a prompt accessing for Cloudformation to allow the creation of resources that need approval. Type y
and press ENTER
to continue with the deployment:
You'll start seeing the output from Cloudformation in your console as the stack is being created.
When the stack has been successfully deployed, you should see:
Notice the output which we defined at the end of the stack. The CDK printed the public ip address of the newly created instance for us because we told it to, awesome! You can use this method to print out any value when a stack has successfully deployed.
You can alternatively get this information from the ec2 console by checking on the instances dashboard.
Accessing the instance
Let's ssh into our newly created instance with our key file and the public ip address.
Note: that the default user for AMAZON LINUX images is ec2-user
ssh -i ~/.aws/pems/simple-instance-1-key.pem ec2-user@34.220.79.175
You should now be able to log into your instance!
Unfortunately, we have an instance that isn't running anything on it.
Let's fix that!
Adding User script
- Let's create a new file under
./lib/
directory calleduser_script.sh
. Paste this code into that file. - This code will deploy Apache, Wordpress, Mysql server on this intance. In this script, the database password will be
pl55w0rd
. - It's very insecure to add passwords to scripts but in this case I'm doing this just for demonstration purposes.
- In production you should first of all never use such a weak password and secondly, not inside such a script.
- Rather, you should deploy the Mysql database on AWS RDS and setup credentials for that database using AWS Secrets Manager.
Here's the setup file:
#! /bin/bash
# become root user
sudo su
# update dependencies
yum -y update
# we'll install 'expect' to input keystrokes/y/n/passwords
yum -y install expect
# Install Apache
yum -y install httpd
# Start Apache
service httpd start
# Install PHP
yum -y install php php-mysql
# php 7 needed for latest wordpress
amazon-linux-extras -y install php7.2
# Restart Apache
service httpd restart
# Install MySQL
wget http://repo.mysql.com/mysql-community-release-el7-5.noarch.rpm
rpm -ivh mysql-community-release-el7-5.noarch.rpm
yum -y update
yum -y install mysql-server
# Start MySQL
service mysqld start
# Create a database named blog
mysqladmin -uroot create blog
# Secure database
# non interactive mysql_secure_installation with a little help from expect.
SECURE_MYSQL=$(expect -c "
set timeout 10
spawn mysql_secure_installation
expect \"Enter current password for root (enter for none):\"
send \"\r\"
expect \"Change the root password?\"
send \"y\r\"
expect \"New password:\"
send \"pl55w0rd\r\"
expect \"Re-enter new password:\"
send \"pl55w0rd\r\"
expect \"Remove anonymous users?\"
send \"y\r\"
expect \"Disallow root login remotely?\"
send \"y\r\"
expect \"Remove test database and access to it?\"
send \"y\r\"
expect \"Reload privilege tables now?\"
send \"y\r\"
expect eof
")
echo "$SECURE_MYSQL"
# Change directory to web root
cd /var/www/html
# Download Wordpress
wget http://wordpress.org/latest.tar.gz
# Extract Wordpress
tar -xzvf latest.tar.gz
# Rename wordpress directory to blog
mv wordpress blog
# Change directory to blog
cd /var/www/html/blog/
# Create a WordPress config file
mv wp-config-sample.php wp-config.php
#set database details with perl find and replace
sed -i "s/database_name_here/blog/g" /var/www/html/blog/wp-config.php
sed -i "s/username_here/root/g" /var/www/html/blog/wp-config.php
sed -i "s/password_here/pl55w0rd/g" /var/www/html/blog/wp-config.php
# create uploads folder and set permissions
mkdir wp-content/uploads
chmod 777 wp-content/uploads
#remove wp file
rm -rf /var/www/html/latest.tar.gz
We will need to add fs
module at the top of our ./lib/simple-ec2-stack.ts
file since instance.addUserData()
needs to access the file system during deployment.
import * as cdk from '@aws-cdk/core'
import * as ec2 from '@aws-cdk/aws-ec2' // import ec2 library
import * as iam from '@aws-cdk/aws-iam' // import iam library for permissions
// lets include fs module
import * as fs from 'fs'
Then we can the function instance.addUserData()
right before our output function:
...
// add user script to instance
// this script runs when the instance is started
instance.addUserData(
fs.readFileSync('lib/user_script.sh', 'utf8')
)
// cdk lets us output prperties of the resources we create after they are created
// we want the ip address of this new instance so we can ssh into it later
new cdk.CfnOutput(this, 'simple-instance-1-output', {
value: instance.instancePublicIp
})
...
Update the deployed stack
Let's re-synthesize to check everything is okay:
cdk --synth --profile default
- You should now see the user script commands in the
synth
output - Let's deploy our new changes.
- Cloudformation will only update resources that are being updated.
- In this case, only ec2 instance is being updated.
- Other things like roles and security groups will remain as they are since there are no changes to them in the updated stack.
cdk --deploy --profile default
Take note: Since we are not using an elastic IP, its highly likely that the public ip address of the instance has changed.
Let's use the outputted IP address and check to see that Wordpress, Mysql and PHP were installed correctly:
In your browser, navigate to http:///blog
You should then see:
- and then you can complete the installation of Wordpress!
- Remember your database credentials
root
andpl55w0rd
as defined in the script
Testing the stack
Well we know our CDK code works and can provision an ec2 instance to run our Wordpress server and database. Good.
But how can we make sure that changes to the CDK code do not do what we don't want it to do?
This is where tests come in.
Test requirements
- I do not want any instance other
t2.micro
to be used as my server instance type because I always want to remain under AWS free tier usage for EC2. Let's ensure that. - I want to ensure that my instance uses the SSH key with the name
simple-instance-1-key
To accomplish this, we change the code inside the file ./test/simple-ec2.test.ts
to:
import { expect as expectCDK, haveResourceLike } from '@aws-cdk/assert';
import * as cdk from '@aws-cdk/core';
import * as SimpleEc2 from '../lib/simple-ec2-stack';
test('Check InstanceType and SSH KeyName', () => {
const app = new cdk.App();
const stack = new SimpleEc2.SimpleEc2Stack(app, 'MyTestStack');
expectCDK(stack).to(
haveResourceLike('AWS::EC2::Instance', {
InstanceType: 't2.micro',
KeyName: 'simple-instance-1-key'
})
)
});
As you can see from the test code, the test will check the generated Cloudformation template generated by the CDK.
In our case we want to check that the instance is a t2.micro
and that it uses the SSH key simple-instance-1-key
. These are two crucial properties to us.
You can read more about testing infrastructure with the CDK here here
Run the test:
npm test
All good!
And now your code should be able to run a test before deploying your infrastructure! Fantastic!
npm test && cdk deploy --profile default
Destroying the stack
If you would like to destroy the infrastructure you just provisioned, it's as simple as:
cdk destroy --profile default
And Cloudformation will remove your entire stack!
Notes:
- It's not advisable to run mysql on the same instance as your Wordpress server. You can instead use AWS managed Database service RDS to deploy the database that Wordpress will use.
- Don't put sensitive information like passwords in user scripts since in many cases they are committed to source control or their output is visible in a CI/CD console or instance terminal history
Conclusion
The AWS CDK makes writing IaC, provisioning, deploying, updating and destroying infrastructure very painless. You can write tests to make sure you do not deploy the wrong things.
This was a simple example and may seem quite a lot just to deploy an ec2 instance. However, as we progress through the series, you will realize how its very beneficial to complex infrastructure.
Up next
In part 4, using the CDK, we will make our Wordpress server more more production ready. We will:
- create AWS RDS Mysql database instead of running the database on the ec2 instance
- provision this database in an isolated subnet to keep it secure from the public Internet
- use AWS SSM to access our instance instead of an SSH key and gain all the benefits of IAM permissions/roles
- deploy an Application Load Balancer
- Create our EC2 instance with better/more advanced script
- Place the Wordpress instance in an AutoScaling Group
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 (5)
Very useful article. All works fine except .env file variables aren't getting loaded, had to hardcode account and region at last. Is there any step missing?
Did you configure your aws profile in
./aws/config
?Yes, cdk only works when when I'm hardcoding the account/region
require('dotenv').config()
const config = {
env: {
// account: '7339854xxxxx',
// region: 'us-east-1'
account: process.env.AWS_ACCOUNT_NUMBER,
region: process.env.AWS_REGION
}
}
have you checked AWS_ACCOUNT_REGION instead of AWS_REGION only
Let’s say after deploy, I create a new EC2 instance in new VPC with in a same stack. How could I destroy the old EC2 in default VPC during deploy ? Thanks