Deploy Node.js to AWS: Build an Automated CI/CD Pipeline

Last updated on 22 November 2022

#aws#nodejs

Deploying a Node.js application to AWS is not straightforward. One needs to follow many steps, and only when done in the correct order, can one deploy a Node.js application.

That is why I've decided to write a series on how to deploy your Node.js application. There are five areas this series will cover:

  1. ๐Ÿ‘‰ Deploying Node.js to AWS with CI/CD
  2. Automate with CloudFormation and IAAS
  3. Monitoring and alerting using AWS CloudWatch
  4. Securing your Node.js Application with AWS
  5. Achieve high availability on your Node.js application with AWS

There's a lot more you can do with AWS. Let me know what else you want me to cover in this series in the comments.

This first article will go through all the steps required to deploy your Node.js application to AWS and have an automated Continuous Integration & Continuous Deployment (CI/CD) setup.

Here are the ideas we will discuss:

  • ๐ŸŽฏ Overview
  • ๐Ÿฎ Our sample Node.js application
  • ๐Ÿ— Understanding CodeBuild
  • ๐Ÿงฑ Provide env variables to CodeBuild
  • ๐Ÿ•‹ Storing build artifacts
  • ๐Ÿชฉ Introduction to CodeDeploy
  • ๐ŸŒ  Setting up CodeDeploy in an EC2 instance
  • ๐Ÿข Create a CodeDeploy application
  • ๐Ÿ’Ž How to create a deployment (manual & automated way)
  • ๐ŸŽ› Customizing in-place deployments
  • โžซ Rollback
  • ๐Ÿฆ„ Conclusion

Overview

We will build an automated CI/CD pipeline for our Node.js application completely on AWS. We go through a series of steps to deploy our changes to AWS. Once your code changes are approved and merged into the repository's base branch, they go through the following stages:

Software development lifecycle

1Merge to production > Build code > Run tests > Start the deployment

The build stage comprises of type checking, code compilation (eg. TypeScript to JavaScript), running linters, unit, and integration tests, etc.

Once the build stage completes, a deployment is triggered to push the latest code revision to the servers.

Continuous Integration (CI) makes sure your changes are continuously integrated into the main stable branch. Continuous Deployment (CD) automates the deployment process completely. It does not require any manual intervention. This allows faster and frequent releases.

Continuous Delivery is a practice where manual approval is required to go live.

Our sample Node.js application

We will be using a sample Node.js application to deploy to AWS. You can find the code here.

If you want to follow along, follow these steps:

  1. Clone the repo

    1git clone git@github.com:Rishabh570/deploy-nodejs-aws-cicd.git
  2. Install the packages

    1npm install
  3. Run the server

    1npm start

You should have a running Node.js application on your local machine. Let's get to the interesting stuff.

I'll be using the ap-south-1 region throughout the article, feel free to choose based on your location.

On to CodeBuild!

Understanding CodeBuild

Once we have our application running, the next step is building our code. It can involve many things. AWS CodeBuild is a developer tool and it helps in building our code.

Some of the common use cases for CodeBuild are:

  1. Compiling the code (eg. compiling TypeScript)
  2. Running linters and formatter (eg. eslint, prettier).
  3. Testing the code (eg. running unit tests)
  4. Build the image and push it to the docker registry (eg. AWS ECR).

To start using CodeBuild, we first need to create a CodeBuild project. There are a few things required to create one:

  • Code source
  • Environment
  • Batch configuration
  • Artifacts
  • Logs
  • Meta information about the project
  • Build specification file

Let's understand each one in better detail.

Source

CodeBuild requires access to your code repository to run a build. You can use any one of CodeCommit, GitHub, BitBucket, GitHub Enterprise, or Amazon S3. Since our code is hosted on GitHub, we'll use that as the source of code.

We will set the source version to point to the "main" branch. You can choose your production branch. In our case, the "main" branch is the production branch.

Lastly, we will allow CodeBuild to modify the service role (tick the checkbox) so that it can get the necessary permissions.

Environment

This specifies the environment where the build will run. You can choose "Managed Image" for starters. Additionally, follow these steps:

  • Operating system = "Ubuntu".
  • Runtime = "standard"
  • Set the Image to the latest one available. At the time of writing, this is aws/codebuild/standard:6.0

Batch configuration

This setting tells AWS to batch a certain predefined number of builds and run them at once. This is not required but can be considered based on your use case.

Artifacts

You might want to store the files generated by the build process to use or refer to later. You can do it by generating artifacts. You can instruct CodeBuild to store a specific set of files or all the files in Amazon S3 after the build finishes.

We will talk about adding artifacts in the later section.

Logs

If you want access to your build logs, you can choose to store them on Amazon S3. The build logs are encrypted by default, but you can choose to disable encryption and store them as plain text.

Meta information

If the CodeBuild project is a folder then all the builds are files in that folder. A CodeBuild project contains all the builds for a particular repository. You need one CodeBuild project for each of your repositories.

Meta information includes the name of the CodeBuild project and an optional description.

Additionally, you can set the max limit on the number of concurrent builds for a given build project.

Build specification file

This is the most important part of your build configuration. A buildspec.yml (or, buildspec.json) file declares how CodeBuild will build the code. You can check out the AWS CodeBuild reference for detailed information on the syntax and examples.

You can choose to have a buildspec file in your codebase or specify the build commands directly in the editor.

This is what our buildspec.yml file looks like:

buildspec.yml file containing the build instructions

1version: 0.2
2
3phases:
4 install:
5 runtime-versions:
6 nodejs: 18
7 commands:
8 - echo "๐Ÿ“ฆ installing packages..."
9 - echo "โœ… Packages installed successfully."
10 pre_build:
11 commands:
12 - echo "โš™๏ธ Testing..."
13 - echo "โœ… Tests passed successfully."
14 build:
15 commands:
16 - echo "๐Ÿšง Starting compiling packages..."
17 - echo "โœ… Build passed successfully."
18 post_build:
19 commands:
20 - echo "๐Ÿšš Performing post-build packing and operations..."
21 - echo "โœ… Post build successful"

Providing env vars to CodeBuild

If your build uses values that are sensitive, you should not put them as plaintext in the buildspec.yml file. There are two options:

  1. Use ENV variables and provide them directly from CodeBuild editor. Or,
  2. Use AWS parameter store for your secrets and use them directly in your builspec.yml file.

If you observe closely, first approach does not help much. Providing ENV variables right from the CodeBuild dashboard is not only manual effort but it also exposes your ENV variables in the build logs as plaintext. Not good.

This leaves us with only one option โ€“ using the AWS parameter store.

Let's head over to the parameter store and create a new parameter.

We'll store one sample application password and then use it directly in our buildspec.yml file.

Create a secure parameter in AWS parameter store

Parameter store offers three types of values:

  1. String
  2. StringList
  3. SecureString

Make sure to use the SecureString type when creating any sensitive parameter. Once it is done, you can refer to the parameter in your buildspec.yml file like this:

Using secret from parameter store in buildspec.yml

1version: 0.2
2
3env:
4 parameter-store:
5 PASSWORD: /Production/AppPassword
6
7phases:
8 install:
9 runtime-versions:
10 nodejs: 18
11 commands:
12 - echo "๐Ÿ“ฆ installing packages..."
13 - echo "โœ… Packages installed successfully."
14 pre_build:
15 commands:
16 - echo "โš™๏ธ Testing..."
17 - echo "โœ… Tests passed successfully."
18 - echo $PASSWORD
19 build:
20 commands:
21 - echo "๐Ÿšง Starting compiling packages..."
22 - echo "โœ… Build passed successfully."
23 post_build:
24 commands:
25 - echo "๐Ÿšš Performing post-build packing and operations..."
26 - echo "โœ… Post build successful"

With this, our builds have access to our application password. It is encrypted and does not need any manual effort to inject during the build phase.

Store artifacts for future reference

We have successfully set up CodeBuild and the builds are passing. Now we will look at how to add artifact support to your builds.

Following the buildspec official reference, we can add the support for artifact by adding the highlighted section to our buildspec.yml file:

adding artifact support in your buildspec.yml

1version: 0.2
2
3env:
4 parameter-store:
5 PASSWORD: /Production/AppPassword
6
7phases:
8 install:
9 runtime-versions:
10 nodejs: 18
11 commands:
12 - echo "๐Ÿ“ฆ installing packages..."
13 - echo "โœ… Packages installed successfully."
14 pre_build:
15 commands:
16 - echo "โš™๏ธ Testing..."
17 - echo "โœ… Tests passed successfully."
18 - echo $PASSWORD
19 build:
20 commands:
21 - echo "๐Ÿšง Starting compiling packages..."
22 - echo "โœ… Build passed successfully."
23 post_build:
24 commands:
25 - echo "๐Ÿšš Performing post-build packing and operations..."
26 - echo "โœ… Post build successful"
27
28artifacts:
29 files:
30 - '**/*'
31 name: deploy-nodejs-build-artifacts

And we're done, running a fresh build will store the artifacts in Amazon S3 โœจ. Here's a sample build artifact stored on S3:

CodeBuild build artifact gets stored on Amazon S3

First steps with CodeDeploy - understanding appspec.yml

Before we start our deployment journey with CodeDeploy, we need to understand a very crucial piece of the puzzle โ€“ Application Specification File (or, appspec.yml). The appspec.yml instructs CodeDeploy how to go about the deployment process.

The application specification file can be in YAML or JSON format only.

Here's a simple appspec.yml file:

appspec.yml file breaks down deployment stages into steps

1version: 0.0
2os: linux
3files:
4 - source: /
5 destination: /deploy-nodejs-aws-cicd
6hooks:
7 BeforeInstall:
8 - location: scripts/install_dependencies.sh
9 timeout: 300
10 runas: root
11
12 ApplicationStart:
13 - location: scripts/start_server.sh
14 timeout: 300
15 runas: root
16
17 ValidateService:
18 - location: scripts/validate_service.sh
19 timeout: 300

Let's break it down.

version

This represents the version of the Application Specification (or, appspec) file.

os

This is for explicitly specifying the operating system of the machine.

files

It instructs CodeDeploy which files from your source code should be copied over to the EC2 instance(s) during the deployment's install event.

hooks

For each event in a deployment lifecycle, there is a dedicated hook provided by CodeDeploy. Hooks can be used to run scripts against each lifecycle event. You can choose to attach one or more scripts to a single hook. It is not mandatory to include all the hooks in your appspec file.

Here are the following hooks that are available in an appspec file, in the exact run order:

  1. ApplicationStop
  2. DownloadBundle
  3. BeforeInstall
  4. Install
  5. AfterInstall
  6. ApplicationStart
  7. ValidateService
Hooks available in CodeDeploy
DownloadBundle and Install events cannot be scripted.

The list of environment variables that are accessible to hook scripts is available here. On the same page, you can learn about available hooks in case you're using a load balancer. Additionally, the documentation also states which hooks are available for blue-green deployment.

To give a broad overview, here are the steps to deploy your code using CodeDeploy:

  1. Create an EC2 instance.
  2. Install CodeDeploy agent on EC2.
  3. Manually create a CodeDeploy application and a deployment group.
  4. Finally, create a deployment (manually and in an automated way).

Let's take a deeper look at each of these steps.

Create an EC2 instance

We will need one running EC2 instance where we can deploy our changes. You can select a t2.micro Amazon Linux 2 AMI instance to follow along.

Make sure to attach an IAM role that can list and fetch items from Amazon S3. The EC2 instance needs this permission to fetch the revision during the deployment.

Before installing CodeDeploy, add the following tags to your EC2 instance:

  1. name = webserver
  2. environment = development

This is required as CodeDeploy uses tags to target eligible instances.

Install CodeDeploy

To install CodeDeploy in your newly created EC2 instance, follow these steps:

  1. sudo yum update
  2. sudo yum install -y ruby wget
  3. wget https://aws-codedeploy-eu-west-1.s3.eu-west-1.amazonaws.com/latest/install
  4. chmod +x ./install
  5. sudo ./install auto
  6. sudo service codedeploy-agent status

You can find more information in the installation guide for Linux here.

Once done, you should be able to see the CodeDeploy status with a process ID like below:

output for command #6

1AWS CodeDeploy agent is running as PID 27393

Create a CodeDeploy application and deployment group

We have our EC2 instance up and running. And we have installed the CodeDeploy agent. The next step, create a CodeDeploy application and deployment group.

Each application requires a corresponding CodeDeploy application for deployments. A deployment group is required to target a set of EC2 instances (using name and environment tags).

To create a CodeDeploy application:

  1. Visit CodeDeploy.
  2. There are only two things required โ€“ application name and compute platform. You can name the application anything you like. We will be choosing "EC2/On-premises" for the computing platform.

Next up, let's create a deployment group. To do this, we will need to create an IAM role for CodeDeploy for it to access the EC2 instance(s).

  1. Head over to IAM > create role.
  2. Search for "CodeDeploy" in AWS services (shown below).
  3. Select and name your role "CodeDeployRoleForEC2".
  4. Review and click "Create Role".
Create an IAM role for CodeDeploy EC2

Finally, let's get to the last step of creating a deployment group before we can trigger a deployment.

Create a deployment group in CodeDeploy

Here are the steps:

  1. Enter the deployment group name (aka DGN) = development.
  2. Select the service role you just created for CodeDeploy.
  3. For the deployment type, we will use "In-place".
  4. Select "Amazon EC2 instances" for the environment configuration. Additionally, add the tags to help CodeDeploy select the eligible EC2 instances.
  5. Key = environment, Value = development
  6. Choose deployment settings = CodeDeployDefault.AllAtOnce (explained later).
  7. Disable the load balancer.
  8. Finally, click "Create deployment group".
You can choose to have CodeDeploy automatically installed & be up-to-date on all the instances. For this, you need AWS Systems Manager installed on your instances. This is only required if you select "AWS EC2 instances" at the Environment Configuration stage.

Create a deployment

Finally, time to create a deployment. There are two ways to do this:

  1. Manually creating the deployment.
  2. Automating the deployment by updating our build stage (buildspec.yml).

Regardless of the method you choose, you'll need to upload the code revision on Amazon S3 (or GitHub) first. CodeDeploy picks up the revision file (.zip) and creates a deployment using that.

Let's create a separate S3 bucket to host our code revisions.

Using profile flag (--profile) to select AWS region and credentials for make-bucket command

1aws s3 mb s3://deploy-nodejs-deployment-revisions --profile personal-mumbai

You should see the following success output:

Create an AWS S3 bucket using AWS CLI

It is a good idea to have bucket versioning enabled, you can do that by running this command (can be done from AWS console as well):

Enabling Amazon S3 bucket versioning

1aws s3api put-bucket-versioning --bucket deploy-nodejs-deployment-revisions --versioning-configuration Status=Enabled --profile personal-mumbai

Upon checking the properties of the bucket from the AWS console, we can confirm that the versioning has been enabled:

Enable bucket versioning on an AWS S3 bucket
You can use AWS named profiles to store different credentials, region, and output preferences.

Manually creating a deployment

We will upload our code revision to the bucket by following this command:

Uploading revision zip file to Amazon S3

1aws deploy push --application-name deploy-nodejs-codeDeploy --s3-location s3://deploy-nodejs-deployment-revisions/development/source.zip --ignore-hidden-files --profile personal-mumbai

AWS CLI conveniently tells us to deploy with the revision just uploaded. This command will help us automate the deployment soon. But first, let's look at how we can do this from the AWS console.

Push code revision to Amazon S3

Our revision is now available on the S3 bucket:

Let's create a deployment now.

Create a deployment in CodeDeploy
  1. Click "Create deployment" from the deployment group details page (shown below).
  2. Select the deployment group. In our case, it is "development".
  3. Make sure the revision type is set to S3.
  4. Select the revision location.
  5. Finally, click "Create deployment".

After following the above steps, you should have your latest revision deployed on your EC2 instance(s).

Automating the deployment

To automate the deployment, we can inject the steps shown above (in the manual deployment section) into the buildspec.yml file. CI/CD workflow:

CI/CD on AWS

Here's our updated builspec.yml file:

Using builspec.yml > post_build hook to deploy our changes once the build succeeds

1version: 0.2
2
3env:
4 parameter-store:
5 PASSWORD: /Production/AppPassword
6
7phases:
8 install:
9 runtime-versions:
10 nodejs: 18
11 commands:
12 - echo "๐Ÿ“ฆ installing packages..."
13 - echo "โœ… Packages installed successfully."
14 pre_build:
15 commands:
16 - echo "โš™๏ธ Testing..."
17 - echo "โœ… Tests passed successfully."
18 - echo $PASSWORD
19 build:
20 commands:
21 - echo "๐Ÿšง Starting compiling packages..."
22 - echo "โœ… Build passed successfully."
23 post_build:
24 commands:
25 - echo "๐Ÿšš Performing post-build packing and operations..."
26 - aws deploy push --application-name deploy-nodejs-codeDeploy --s3-location s3://deploy-nodejs-deployment-revisions/development/source.zip --ignore-hidden-files --profile personal-mumbai
27 - aws deploy create-deployment --application-name deploy-nodejs-codeDeploy --s3-location bucket=deploy-nodejs-deployment-revisions,key=development/source.zip,bundleType=zip,eTag=80e501480a8545019660e87ca42a6f00,version=67ZE9Q8CZeo9XszaZ4F.eKmrlW7mnDrm --deployment-group-name development --deployment-config-name CodeDeployDefault.AllAtOnce --description "This deployment aims to deploy our code to the eligible EC2 instance(s)."
28 - echo "โœ… Post build successful"
29
30artifacts:
31 files:
32 - '**/*'
33 name: deploy-nodejs-build-artifacts

If you are following along, you can try this out by pushing or merging your changes to the main branch (your production branch). And this successfully automates the whole CI/CD workflow.

PS: This is one of the possible ways to achieve CI/CD on AWS. There are other ways to achieve the same outcome (eg. using AWS CodePipeline).

Customizing In-place deployments

Deploying to all the instances at once works fine on the development environment. But when it comes to production, it is risky to deploy the changes to all the instances at once. AWS, by default, provides the option of deploying changes one instance at a time.

You can even choose to have a custom deployment configuration. For example, you might want at least 50% of your instances to be healthy at any point. You can create a custom deployment configuration for that.

The blue-green deployment configuration works best with AWS auto-scaling groups (ASG) and load balancer. We will take a deeper look at the blue-green deployment configuration later in the series.

Rollbacks

There are times when the deployment introduces some unexpected behaviors. We need a way to cut the impact area of the bug as quickly as possible.

Rollbacks help in such scenarios.

Rollbacks allow you to move to a safe & stable previous build in face of a production bug. But, previous deployments are not overridden during the process. A new deployment with the previous stable code revision is deployed instead.

There are two ways of performing rollback in CodeDeploy:

  1. Manual rollback
  2. Automatic rollback

For manual rollbacks, we need to perform the rollback manually every time. To do this, create a deployment with any previous stable code revision.

To set up automatic rollbacks, we will edit our "development" deployment group. In the advanced options, you can find Alarms and Rollbacks.

There are two scenarios where automatic rollback is triggered:

  1. When a deployment fails.
  2. When some alarm threshold is met.

The first one is straightforward. And AWS gives a lot of flexibility for setting up alarm thresholds. You can set up CloudWatch Alarms and use those to determine if a rollback is necessary.

For example, I've set up a CloudWatch Alarm on the EC2 CPU utilization. If it exceeds 60%, there will be an automatic rollback to a previous stable code revision.

CodeDeploy configuration to rollback deployment when Alarm goes off

You can read more about various kinds of CloudWatch Alarms here.

Conclusion

This wraps up our journey on Node.js CI/CD on AWS. On every Pull Request (PR) merge to the production branch, the changes will be built, tested, and deployed automatically.

In the next article, I'll write about how we can automate some manual tasks using AWS CloudFormation. Why? Because it becomes harder to maintain and operate the AWS infrastructure manually when your applications starts to scale.

I hope this article helped you get an initial understanding on the capabilities of AWS. And what you can expect next.

If you were not following along, this is the best opportunity for you to go ahead and learn by doing. It was a lot, make sure you understand. Final code is available here.

Happy to answer any questions and take feedback in the comments or on my Twitter.

๐Ÿ“Œ You might also like:

  1. Sessions vs Tokens: How to authenticate in Node.js
  2. Complete Guide to Multi-Provider OAuth 2 Authorization in Node.js
  3. Learn How to Use Group in Mongodb Aggregation Pipeline (With Exercise)
ย 
Liked the article? Share it on: Twitter
No spam. Unsubscribe at any time.