AWS CDK: Deploy a web-server to AWS Fargate with CI/CD using AWS CodePipeline and Github
Tags: cdk, node, fargate, cicd
Published: 2023-07-26 09:45:20

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](https://github.com/AceTheNinja/aws-cdk-nestjs-starter)

**Pre-requisites:**

1. Understanding of docker
2. Basics of Networking
   - VPC
   - Subnets
3. Knowledge of AWS Services, namely:
   - AWS CodeBuild
   - AWS CodePipeline
   - AWS Elastic Load Balancer
   - AWS Certificate Manager
   - Elastic Container Service
   - CloudFormation
4. AWS CLI and cdk toolkit configured on your system
5. Your Github token with `repo` and `webhook` access; stored in AWS secrets manager
6. Your domain certificate added on AWS Certificate Manager

Let's Begin.

**Step 1:**
**Containerize your app**

> For the demo we'll use this [repo] (https://github.com/nestjs/typescript-starter)

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.

```dockerfile
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

```bash
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

```ts
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.

```ts
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`

```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`
```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`

```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.

```ts
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`
```json
{
  ...
  "scripts": {
     ...
     "cdk:synth": "cdk synth",
     "cdk:deploy": "cdk deploy"
  }
}
```

`cdk.json`
```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.