Introduction

In this post, we’ll deploy an EC2 Instance using AWS CDK. With this instance, we’ll also configure Security Groups, IAM Role, and User Data for our instance which will be a custom shell script that will run on initialization.

Let’s start with opening a directory and creating our CDK application.

# Let's create a folder for our CDK application.
mkdir -p practical-cdk/ec2-instance && cd $_

# Initialize the CDK application.
npx aws-cdk@2.x init app --language typescript

It’s totally optional, but let’s commit this initial state.

git add .
git commit -m 'Initial commit for ec2-instance-stack'

Now we can start writing our constructor.

The EC2 Instance Stack

First, we should import the necessary libraries. In the ./lib/ec2-instance-stack.ts file, add the following modules.

import * as path from 'path';
import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as s3Assets from 'aws-cdk-lib/aws-s3-assets';

As a good practice, I usually start with defining the resource I’ll create as class attributes. That way, when I need them in different stacks in the same application, I can easily access them.

export class Ec2InstanceStack extends Stack {
  vpc: ec2.IVpc;

  ec2Instance: ec2.Instance;

  ec2InstanceInitScriptPath: string;
  ec2InstanceInitScriptS3Asset: s3Assets.Asset;

  ec2InstanceIAMRole: iam.Role;
  ec2InstanceSecurityGroup: ec2.SecurityGroup;

  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);
  }
}

Now, we can start filling our constructor method.

Constructors

Following constructors as a whole should be in the Ec2InstanceStack.

////////////////////////////////////////////////////////////////////////////////////////////////////
//
// VPC
//
this.vpc = ec2.Vpc.fromLookup(this, 'ec2-instance-vpc', {
    isDefault: true
});

////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Security Groups
//
this.ec2InstanceSecurityGroup = new ec2.SecurityGroup(this, 'ec2-instance-security-group', {
    vpc: this.vpc,
    securityGroupName: 'ec2-instance-security-group',
    description: `EC2 Instance Security Group`,
    allowAllOutbound: true
});
cdk.Tags.of(this.ec2InstanceSecurityGroup).add(
    'Name',
    'ec2-instance-security-group'
);

////////////////////////////////////////////////////////////////////////////////////////////////////
//
// IAM
//
this.ec2InstanceIAMRole = new iam.Role(this, 'ec2-instance-role', {
    roleName: 'ec2-instance-role',
    assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
    managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore')
    ],
    inlinePolicies: {},
    // For now, we don't need any inline policies.
    // However, if you need any inline policies, here is
    // an example for it;
    //
    // inlinePolicies: {
    //   EC2InstancePolicies: new iam.PolicyDocument({
    //     statements: [
    //       new iam.PolicyStatement({
    //         effect: iam.Effect.ALLOW,
    //         actions: [
    //           's3:*',
    //           'logs:*',
    //         ],
    //         resources: [
    //           '*'
    //         ]
    //       }),
    //       new iam.PolicyStatement({
    //         effect: iam.Effect.ALLOW,
    //         actions: [
    //           'sts:AssumeRole'
    //         ],
    //         resources: [
    //           'arn:aws:iam::*:role/*'
    //         ]
    //       }),
    //     ]
    //   })
    // }
});

////////////////////////////////////////////////////////////////////////////////////////////////////
//
// EC2 Instance
//
this.ec2Instance = new ec2.Instance(this, 'ec2-instance', {
    vpc: this.vpc,
    instanceName: 'ec2-instance',
    instanceType: ec2.InstanceType.of(
        ec2.InstanceClass.T2,
        ec2.InstanceSize.MICRO
    ),
    blockDevices: [
        {
            deviceName: '/dev/xvda',
            volume: ec2.BlockDeviceVolume.ebs(8), // 8 GB
        }
    ],
    machineImage: new ec2.AmazonLinuxImage({
        generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2,
        cpuType: ec2.AmazonLinuxCpuType.X86_64,
    }),
    securityGroup: this.ec2InstanceSecurityGroup,
    role: this.ec2InstanceIAMRole,
    userDataCausesReplacement: true
});

////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Initialization Script
//
this.ec2InstanceInitScriptS3Asset = new s3Assets.Asset(this, 'ec2-instance-init-script', {
    path: path.join(__dirname, '../lib/scripts/initial-setup.sh')
});

this.ec2InstanceInitScriptPath = this.ec2Instance.userData.addS3DownloadCommand({
    bucket: this.ec2InstanceInitScriptS3Asset.bucket,
    bucketKey: this.ec2InstanceInitScriptS3Asset.s3ObjectKey,
});

const initScriptWrapper = `sudo -i -u ec2-user bash ${this.ec2InstanceInitScriptPath} argument1 argument2`
this.ec2Instance.userData.addCommands(
    initScriptWrapper
);
this.ec2InstanceInitScriptS3Asset.grantRead(this.ec2Instance.role);

User Data

As we defined above, the initialization script should be in the path ./lib/scripts/initial-setup.sh. This, of course, is totally up to you.

Let’s create the sh file at this path and make it executable.

mkdir -p lib/scripts && cd $_
touch initial-setup.sh
chmod +x initial-setup.sh

We are ready to write our script. In the initial-setup.sh file, we can put whatever we need for our instance.

#!/bin/bash -xe

# ##################################################
#
# Initial setup script for our EC2 instance.
#
echo "Starting initialization script"

echo "Updating libraries"
sudo yum update -

echo "Installing golang for the sake of example"
sudo yum install golang -y

echo "Also printing given arguments"
echo $1
echo $2

As a final step, we should uncomment the env prop in the Ec2InstanceStack definition. Open ./bin/ec2-instance.ts and change the stack constructor with the following code.

new Ec2InstanceStack(app, 'Ec2InstanceStack', {
  env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
});

Deployment and Follow-Up

Let’s check if we missed something by running the synthesizer.

npm run cdk synth

If the CloudFormation template is created without any problem, we are ready to apply our stack.

npm run cdk bootstrap   # If it's the first time you are deploying a CDK application
npm run cdk deploy

Check the changes and approve them if it looks OK.

At this point, we can check the status of the EC2 Dashboard on AWS Console. Once the instance starts initializing, we can try to connect via AWS SSM.

aws ssm start-session --target <INSTANCE_ID> --region <REGION>

To check the status of our initialization script, we can run the following command inside the instance.

sudo tail -f /var/log/cloud-init-output.log

When it’s complete, it should print out something similar to this:

.
.
.

Complete!
Also printing given arguments
argument1
argument2
ci-info: no authorized ssh keys fingerprints found for user ec2-user.
Cloud-init v. 19.3-45.amzn2 finished at Fri, 08 Apr 2022 19:06:23 +0000. Datasource DataSourceEc2.  Up 68.37 seconds

Clean Up

To clean up what we’ve done, we can simply run cdk destroy and approve. It’ll take care of everything.

npm run cdk destroy