We all know infrastructure as code (IAC) is important and with AWS CDK, amazon is bringing infrastructure creation and deployment in the hands of the engineers building the software.
AWS CDK is extremely accessible since it's code can be written is Typescript, Javascript, C#, Java, Go and Python.
In this article we're going to deploy a Nest.js node server on AWS Fargate with CI/CD setup on AWS CodePipeline and we'll be using AWS CDK with Typescript to do this.
If you don't want to go through the whole article, here's a link to the Github repo
Pre-requisites:
- Understanding of docker
- Basics of Networking
- VPC
- Subnets
- Knowledge of AWS Services, namely:
- AWS CodeBuild
- AWS CodePipeline
- AWS Elastic Load Balancer
- AWS Certificate Manager
- Elastic Container Service
- CloudFormation
- AWS CLI and cdk toolkit configured on your system
- Your Github token with
repo
andwebhook
access; stored in AWS secrets manager - Your domain certificate added on AWS Certificate Manager
Let's Begin.
Step 1:
Containerize your app
For the demo we'll use this repo
Since Fargate is a container deployment service, we'll containize our app using docker. Here's the Dockerfile
we'll be adding to the root of our project.
FROM node:18.17.0-slim
RUN npm i -g @nestjs/cli
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
CMD npm run start:prod
Adding the .dockerignore
here for good measure
.env*
.git
node_modules/
infra/
cdk.out/
Step 2:
Add dev dependencies, create the folder structure for your infra code and setup env.ts
Run this command in the root of your project
npm i -D aws-cdk-lib constructs
Your infra code will be part of the directory structure itself so create the following directory structure in the root of your project.
app-root
├── infra
├── stacks
├── cluster.ts
├── pipeline.ts
├── server.ts
├── env.ts
├── index.ts
Don't worry we'll go through each file one by one.
Lastly, setup your env.ts
file
export default {
stack: {
id: 'aws-cdk-demo-1',
},
cluster: {
vpc: {
id: 'vpc-1',
name: 'starter-vpc',
},
ecsCluster: {
id: 'ecs-cluster-1',
},
},
server: {
securityGroup: {
id: 'server-sg',
},
port: 3000,
environmentVariables: {},
iamRole: {
id: 'server-role',
},
loadBalancer: {
id: 'server-lb',
certificateArn: '<CERT_ARN>',
},
ecr: {
repoId: 'ecr-repo-1',
},
},
pipeline: {
id: 'pipeline-1',
source: {
github: {
owner: 'AceTheNinja',
repo: 'aws-cdk-nestjs-starter',
tokenSecretName: '<GITHUB_TOKEN_SECRET_NAME>',
branch: 'master',
},
},
build: {
id: 'build-1',
},
},
};
You can replace <GITHUB_TOKEN_SECRET_NAME>
with the secret name stored in AWS Secrets Manager and <CERT_ARN>
with the ARN from AWS Certificate Manager of your domain.
The server.environmentVariables
can be set if your app requires any env variables to be loaded.
Step 3:
Create an ECS Cluster
Starting with stacks/cluster.ts
We'll create a new VPC for your application and deploy an ECS cluster within it.
import { Construct } from 'constructs';
import { aws_ecs as ecs, aws_ec2 as ec2, CfnOutput } from 'aws-cdk-lib';
import env from '../env';
class Cluster extends Construct {
readonly ecsCluster: ecs.Cluster;
readonly vpc: ec2.Vpc;
constructor(scope: Construct, id: string) {
super(scope, id);
// Create a new VPC
this.vpc = new ec2.Vpc(this, env.cluster.vpc.id, {
vpcName: env.cluster.vpc.name,
});
// Creates a new ECS cluster in the VPC created above.
this.ecsCluster = new ecs.Cluster(this, env.cluster.ecsCluster.id, {
vpc: this.vpc,
});
this.output();
}
output() {
// create a cloudformation output for the ARN of the ECS cluster
new CfnOutput(this, 'ECSCluster_ARN', {
value: this.ecsCluster.clusterArn,
});
}
}
export { Cluster };
Step 4:
Create the Fargate task and service
Now we'll setup our task definition and service on Fargate in the file stacks/server.ts
import { Construct } from 'constructs';
import {
aws_ec2 as ec2,
aws_ecs as ecs,
aws_ecr as ecr,
aws_certificatemanager as acm,
aws_iam as iam,
CfnOutput,
Duration,
aws_ecs_patterns as ecsPatterns,
} from 'aws-cdk-lib';
import { Cluster } from './cluster';
import { Protocol } from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import env from '../env';
interface WebAppProps {
readonly cluster: Cluster;
}
class WebApp extends Construct {
private fargateService: ecsPatterns.ApplicationLoadBalancedFargateService;
public readonly service: ecs.IBaseService;
public readonly containerName: string;
public readonly ecrRepo: ecr.Repository;
public readonly securityGroup: ec2.SecurityGroup;
constructor(scope: Construct, id: string, props: WebAppProps) {
super(scope, id);
// Creates a new security group for your fargate service
this.securityGroup = new ec2.SecurityGroup(
this,
env.server.securityGroup.id,
{
vpc: props.cluster.vpc,
allowAllOutbound: true,
description: `security group for server`,
},
);
// Adding inbound port to the security group
this.securityGroup.addIngressRule(
ec2.Peer.anyIpv4(),
ec2.Port.tcp(env.server.port),
'Ingress rule for webserver',
);
this.fargateService = this.createService(props.cluster.ecsCluster);
this.ecrRepo = new ecr.Repository(this, env.server.ecr.repoId);
this.ecrRepo.grantPull(this.fargateService.taskDefinition.executionRole);
this.service = this.fargateService.service;
this.containerName =
this.fargateService.taskDefinition.defaultContainer.containerName;
this.addAutoScaling();
this.output();
}
private createService(cluster: ecs.Cluster) {
// Creates a new ECS service
const server = new ecsPatterns.ApplicationLoadBalancedFargateService(
this,
'Service',
{
// The cluster in which to create this service
cluster: cluster,
// The number of tasks to run
desiredCount: 1,
// The image to use when running the tasks
taskImageOptions: {
image: ecs.ContainerImage.fromAsset('.'),
containerPort: env.server.port,
environment: env.server.environmentVariables,
enableLogging: true,
taskRole: new iam.Role(this, env.server.iamRole.id, {
assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName(
'CloudWatchFullAccess',
),
],
}),
},
// The load balancer to use
publicLoadBalancer: true,
// The certificate to use for HTTPS
certificate: acm.Certificate.fromCertificateArn(
this,
env.server.loadBalancer.id,
env.server.loadBalancer.certificateArn,
),
// The type of DNS record to use
recordType: ecsPatterns.ApplicationLoadBalancedServiceRecordType.CNAME,
// The security group to use
securityGroups: [this.securityGroup],
},
);
// Enable cookie stickiness on the target group for websockets
server.targetGroup.enableCookieStickiness(Duration.days(1));
// Configure the health check
server.targetGroup.configureHealthCheck({
path: '/',
unhealthyThresholdCount: 5,
protocol: Protocol.HTTP,
port: env.server.port,
});
return server;
}
// Add autoscaling to the Fargate service
private addAutoScaling() {
const autoScalingGroup = this.fargateService.service.autoScaleTaskCount({
minCapacity: 1,
maxCapacity: 3,
});
autoScalingGroup.scaleOnCpuUtilization('CpuScaling', {
targetUtilizationPercent: 50,
scaleInCooldown: Duration.seconds(60),
scaleOutCooldown: Duration.seconds(60),
});
}
// Create an AWS CloudFormation output for the Amazon ECR repository name and ARN
private output() {
new CfnOutput(this, 'ECRRepo_ARN', { value: this.ecrRepo.repositoryArn });
new CfnOutput(this, 'ContainerName', { value: this.containerName });
}
}
export { WebApp, WebAppProps };
Step 5:
Setup CI/CD pipeline with Github and CodePipeline
Finally we'll setup the CI/CD pipeline. For this you'll have to add a buildspec.yml
to the root of your folder so it can be utilised by CodeBuild to build an image of your server which can be deployed to Fargate.
buildspec.yml
version: '0.2'
phases:
install:
runtime-versions:
nodejs: '18.x'
pre_build:
commands:
- aws --version
- COMMIT_HASH=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)
- IMAGE_TAG=${COMMIT_HASH:=latest}
build:
commands:
- docker build -t $REPOSITORY_URI:latest .
- docker tag $REPOSITORY_URI:latest $REPOSITORY_URI:$IMAGE_TAG
post_build:
commands:
- aws ecr get-login-password --region ap-south-1 | docker login --username AWS --password-stdin $REPOSITORY_URI
- docker push $REPOSITORY_URI:latest
- docker push $REPOSITORY_URI:$IMAGE_TAG
- printf '[{"name":"%s","imageUri":"%s"}]' $CONTAINER_NAME $REPOSITORY_URI:$IMAGE_TAG > imagedefinitions.json
artifacts:
files:
- imagedefinitions.json
and now onto the CI/CD pipeline code in stacks/pipeline.ts
import {
CfnOutput,
SecretValue,
aws_codebuild as codebuild,
aws_codepipeline as codepipeline,
aws_codepipeline_actions as codepipeline_actions,
aws_ecr as ecr,
aws_ecs as ecs,
} from 'aws-cdk-lib';
import { WebApp } from './server';
import { Construct } from 'constructs';
import env from '../env';
interface PipelineProps {
readonly webapp: WebApp;
}
class Pipeline extends Construct {
private readonly webapp: WebApp;
readonly service: ecs.IBaseService;
readonly containerName: string;
readonly ecrRepo: ecr.Repository;
public readonly pipeline: codepipeline.Pipeline;
constructor(scope: Construct, id: string, props: PipelineProps) {
super(scope, id);
this.webapp = props.webapp;
this.service = this.webapp.service;
this.ecrRepo = this.webapp.ecrRepo;
this.containerName = this.webapp.containerName;
this.pipeline = this.createPipeline();
this.output();
}
// Creates the pipeline structure
private createPipeline(): codepipeline.Pipeline {
const sourceOutput = new codepipeline.Artifact();
const buildOutput = new codepipeline.Artifact();
return new codepipeline.Pipeline(this, env.pipeline.id, {
stages: [
this.createSourceStage('Source', sourceOutput),
this.createImageBuildStage('Build', sourceOutput, buildOutput),
this.createDeployStage('Deploy', buildOutput),
],
});
}
// Create a stage that retrieves source code from GitHub
private createSourceStage(
stageName: string,
output: codepipeline.Artifact,
): codepipeline.StageProps {
const githubAction = new codepipeline_actions.GitHubSourceAction({
actionName: 'Github_Source',
owner: env.pipeline.source.github.owner,
repo: env.pipeline.source.github.repo,
oauthToken: SecretValue.secretsManager(env.pipeline.source.github.tokenSecretName),
branch: env.pipeline.source.github.branch,
output: output,
});
return {
stageName: stageName,
actions: [githubAction],
};
}
// Create the pipeline build stage
private createImageBuildStage(
stageName: string,
input: codepipeline.Artifact,
output: codepipeline.Artifact,
): codepipeline.StageProps {
const project = new codebuild.PipelineProject(this, env.pipeline.build.id, {
environment: {
buildImage: codebuild.LinuxBuildImage.STANDARD_5_0,
privileged: true,
},
environmentVariables: {
REPOSITORY_URI: { value: this.ecrRepo.repositoryUri },
CONTAINER_NAME: { value: this.containerName },
},
cache: codebuild.Cache.local(codebuild.LocalCacheMode.DOCKER_LAYER),
});
this.ecrRepo.grantPullPush(project.grantPrincipal);
const codebuildAction = new codepipeline_actions.CodeBuildAction({
actionName: 'CodeBuild_Action',
input: input,
outputs: [output],
project: project,
});
return {
stageName: stageName,
actions: [codebuildAction],
};
}
// Create the pipeline deploy stage
createDeployStage(
stageName: string,
input: codepipeline.Artifact,
): codepipeline.StageProps {
const ecsDeployAction = new codepipeline_actions.EcsDeployAction({
actionName: 'ECSDeploy_Action',
input: input,
service: this.service,
});
return {
stageName: stageName,
actions: [ecsDeployAction],
};
}
output() {
// create a cloudformation output for the ARN of the pipeline
new CfnOutput(this, 'Pipeline ARN', {
value: this.pipeline.pipelineArn,
});
}
}
export { Pipeline, PipelineProps };
Step 6:
Instantiate all constructs into your CloudFormation Stack
Here we'll bring all our constructs together for instantiation in the infra/index.ts
file.
import { WebApp } from './stacks/server';
import { Pipeline } from './stacks/pipeline';
import { Cluster } from './stacks/cluster';
import { App, Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import env from './env';
class WebStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
const cluster = new Cluster(this, 'Cluster');
const webapp = new WebApp(this, 'WebApp', {
cluster: cluster,
});
const pipeline = new Pipeline(this, 'Pipeline', {
webapp: webapp,
});
}
}
const app = new App();
new WebStack(app, env.stack.id);
app.synth();
Step 7:
Running CDK
To link and run cdk with our app we'll have to do some housekeeping work, viz. updating package.json
to add cdk synth and deploy commands and add cdk.json
file to tell cdk how to run the infra code.
package.json
{
...
"scripts": {
...
"cdk:synth": "cdk synth",
"cdk:deploy": "cdk deploy"
}
}
cdk.json
{
"app": "npx ts-node infra/index.ts"
}
Since our infra code is written in typescript we'll be using ts-node
package to run it.
Finally, to test out the implementation we can run npm run cdk:synth
and to deploy the code use npm run cdk:deploy
.
Note:
Deploying this on M1 & M2 Mac is causing the docker container to crash. Please use this code with either Intel Macs or Linux.
Top comments (0)