DEV Community

Tapan Rai
Tapan Rai

Posted on • Updated on

AWS CDK: Deploy a web-server to AWS Fargate with CI/CD using AWS CodePipeline and Github

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:

  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

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
Enter fullscreen mode Exit fullscreen mode

Adding the .dockerignore here for good measure

.env*
.git
node_modules/
infra/
cdk.out/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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',
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

cdk.json

{
  "app": "npx ts-node infra/index.ts"
}
Enter fullscreen mode Exit fullscreen mode

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)