Build a CI/CD with GitHub Actions to Deploy a Node.js API

Photo by Bernhard / Unsplash
Photo by Bernhard / Unsplash

Continuous Integration and Continuous Deployment (CI/CD) have become an essential practice for enterprises aiming to deliver software efficiently. A CI/CD automates the process of building, testing, and deploying applications, enabling teams to detect and fix issues early in the development cycle.

A well-implemented CI/CD pipeline brings numerous benefits:

  • Faster Time-to-Market: Automated builds and deployments significantly reduce the time between code commits and production releases.
  • Improved Code Quality: Automated testing at every stage ensures bugs are caught early, reducing the cost of fixes and improving overall software quality.
  • Increased Developer Productivity: Developers can focus on writing code instead of managing manual deployments and repetitive tasks.
  • Deterministic Deployments: Automation eliminates human error in the deployment process, ensuring consistent and reliable releases across different environments.

GitHub Actions is a robust CI/CD platform integrated directly into GitHub repositories that provides all the tools needed to automate your software delivery pipeline.

In this guide, we'll explore how to set up a CI/CD pipeline for a Node.js application using GitHub Actions.

What we will build

I have a Node.js application packaged as a Docker image; when I edit the source code, make a Git commit, and push the changes to the GitHub remote repository, A CI/CD pipeline to run tests, build the docker image, and publish it to the Docker Hub.

After the Docker image is published, the deployment will be triggered on the Virtual Private Server. Once the deployment is done, verify the changes are visible on the online application.

Below is the diagram of the whole workflow:

CI/CD workflow with GitHub Actions to deploy a Node.js application running on Docker.
CI/CD workflow with GitHub Actions to deploy a Node.js application running on Docker.

Prerequisites

To follow this tutorial, make sure you have the following tools installed on your computer.

Set up the project

As a starter project, we will use the final project code of the blog post about building the Docker image of a Node.js project.

The source code of the above project can be found in this GitHub repository. You can fork the repository and then clone it locally.


git clone https://github.com/<username>/node-deploy-cicd.git

cd node-deploy-cicd

Replace the <username> with your GitHub username.

The project is now available locally; create a file ".env" from the ".env.example" file and update the content with the code below:


HOST=http://localhost
PORT=4500

DB_HOST=mongodb
DB_PORT=27017
DB_USER=app_user
DB_PASS=app_password
DB_NAME=admin

When starting a Docker container for our application image, the environment variables defined in the above file will be injected.

At the project root directory, run the following commands to run the project locally:


docker build -t node-rest-api -f Dockerfile-no-bundle .

docker network create node-rest-api-network

docker run -d --network node-rest-api-network -e MONGO_INITDB_ROOT_USERNAME=app_user -e MONGO_INITDB_ROOT_PASSWORD=app_password --name mongodb mongo:8.0

docker run -it -p 4500:4500 --network node-rest-api-network --name node-rest-api --rm --env-file .env node-rest-api:latest

The project will start on port 4500; navigate to the URL http://localhost:4500/documentation.

The project works locally; we will configure the deployment with GitHub Actions in the following steps.

Initialize the GitHub Actions file

The GitHub Actions files are stored in the folder ".github/workflows". Inside this folder, you can organize your workflow files as you want.

Let's create a file named "build.yaml" and add the code below:


name: Backend API workflow
on:
  pull_request:
    branches: [ main ]
    paths:
      - 'src/**'
      - '.github/workflows/build.yaml'
  push:
    branches: [ main ]
    paths:
      - 'src/**'
      - '.github/workflows/build.yaml'

jobs:

In the above code, we define the name of the GitHub Action; you should use a name that helps you quickly identify the action when it is running.

We define two events that trigger the GitHub Action workflow.

  • pull_request: when we open a pull request from any branch to the main branch.
  • push: when we push from the local main branch to the remote one.

But we added another constraint: for these two events, the action is only triggered if the edited files are the GitHub Action file itself or any file under the folder "src".

This helps avoid running the workflow when edited files do not require a new deployment in production (readme, linting with biome, test files, etc.).

For other available events, check out the official documentation link.

The "jobs" property will hold all the pipeline jobs we need. Check out this link to learn more about GitHub Action jobs.

Define the GitHub Actions jobs

We will need three pipeline jobs for this project; they will be executed sequentially, and some will depend on others.

It is possible to have many jobs running parallel depending on your needs. Let's write the first job.

Create a GitHub Actions job for building the project

This GitHub action workflow job will have the following responsibilities:

  • Cloning the project into the GitHub VM instance running our action.
  • Set the Node.js version to use; we will use version 20.
  • Install the Node.js dependencies and cache them so we can reuse them every time the workflow runs. This will speed up the dependency installation.
  • Build the project to transpile the TypeScript into JavaScript files.
  • Run the tests.

Update the file "build.yaml" to add the code below under the "jobs" property:


jobs:
  project-build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Cache Node.js modules
        uses: actions/cache@v4
        with:
          path: node_modules
          key: "${{ runner.os }}-yarn-${{ hashFiles('**/*.yarn.lock') }}"
          restore-keys: ${{ runner.os }}-yarn-

      - name: Set up Node 20
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          registry-url: 'https://registry.npmjs.org'

      - name: Install Node.js dependencies
        run: yarn install

      - name: Build the project
        run: yarn build

      - name: Execute tests
        run: yarn test

The job runs on the latest version of Ubuntu. Regarding dependencies caching, the cache is invalidated when the "yarn.lock" file changes.

Create a Github Actions job for building the Docker image

This GitHub Action workflow job has two constraints:

  1. It depends on the previous job because we don't need to run this one if the previous job fails; it means the application cannot be deployed.
  2. It must be triggered when the branch is merged on the "main" branch. This avoids building a new Docker image whenever you create or update a pull request. Also, if the code is merged on the main branch, it usually means the code integrity is sane.

This job will have the following responsibilities:

Update the file "build.yaml" to add the code below after the "project-build" job:


docker-build:
    if: ${{ github.ref == 'refs/heads/main' }}
    runs-on: ubuntu-latest
    needs:
      - project-build
    steps:
      - uses: actions/checkout@v4

      - name: Cache Node.js modules
        uses: actions/cache@v4
        with:
          path: node_modules
          key: "${{ runner.os }}-yarn-${{ hashFiles('**/*.yarn.lock') }}"
          restore-keys: ${{ runner.os }}-yarn-

      - name: Set up Node 20
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          registry-url: 'https://registry.npmjs.org'

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Dashboard to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          file: Dockerfile-no-bundle
          push: true
          tags: tericcabrel/node-rest-api:latest

We use the predefined GitHub action to build the Docker image, which you can find in the GitHub Action Marketplace.

We provide the credentials to the Docker Hub and the location of the Dockerfile; the Action's job handles the rest.

The two constraints enumerated for this workflow job are defined with the following code.


if: ${{ github.ref == 'refs/heads/main' }}
needs:
  - project-build

Create a Github Actions job for deploying the application

This GitHub Action workflow job has two constraints:

  1. It depends on the "docker-build" job since we cannot deploy anything if no new image is published to the Docker Hub.
  2. It must be triggered when the branch is merged on the "main" branch.

This job will have the following responsibilities:

  • Create a file and store the server's private key where the Node.js application will be deployed.
  • Execute the bash script that will deploy the application.

Update the file "build.yaml" to add the code below after the "docker-build" job:


deploy:
    if: ${{ github.ref == 'refs/heads/main' }}
    runs-on: ubuntu-latest
    needs:
      - docker-build
    steps:
      - uses: actions/checkout@v4
      - name: Add Server key
        run: |
          touch key.txt && echo "${{ secrets.SERVER_KEY }}" > key.txt
          chmod 600 key.txt
      - name: Deploy the application
        env:
          SERVER_HOST: ${{ secrets.SERVER_HOST }}
          SERVER_PORT: ${{ secrets.SERVER_PORT }}
          SERVER_USER: ${{ secrets.SERVER_USER }}
        run: |
          set -e
          ./deploy.sh

The deployment script

This script will perform the following tasks:

  • Connect to the server using the private SSH key
  • Pull the Docker image from the Docker Hub registry
  • Stop the Docker container if it is already running.
  • Create a new Docker container from the newly pulled Docker image.

In the project root directory, create a file named "deploy.sh" and add the code below:


#!/bin/bash

start=$(date +"%s")

ssh -p ${SERVER_PORT} ${SERVER_USER}@${SERVER_HOST} -i key.txt -t -t -o StrictHostKeyChecking=no << 'ENDSSH'
docker pull tericcabrel/node-rest-api:latest

CONTAINER_NAME=node-rest-api
if [ "$(docker ps -qa -f name=$CONTAINER_NAME)" ]; then
    if [ "$(docker ps -q -f name=$CONTAINER_NAME)" ]; then
        echo "Container is running -> stopping it..."
        docker stop $CONTAINER_NAME;
    fi
fi

docker run -d --rm -p 8000:8000 --name $CONTAINER_NAME tericcabrel/node-rest-api:latest

exit
ENDSSH

if [ $? -eq 0 ]; then
  exit 0
else
  exit 1
fi

end=$(date +"%s")

diff=$(($end - $start))

echo "Deployed in : ${diff}s"

An essential part of this code is the following line


ssh -p ${SERVER_PORT} ${SERVER_USER}@${SERVER_HOST} -i key.txt -t -t -o StrictHostKeyChecking=no << 'ENDSSH'

This code establishes an SSH connection to the remote server and executes all the commands between << 'ENDSSH' and ENDSSH.

The flag "-o StrictHostKeyChecking=no" disables the SSH host key verification. Remember that question you get when connecting to a server for the first time?

Host key verification when establishing an SSH connection to a server.
Host key verification when establishing an SSH connection to a server.

This action requires user input, yet in an automated system such as a GitHub Action runner, we cannot interact with the terminal, so adding the above flag skips this SSH host key verification.

Make the script executable using the following command:


chmod +x deploy.sh

Deploy an application on a VPS with GitHub Actions

In the GitHub Action workflow file, we used some secrets variables (${{ secrets.DOCKERHUB_USERNAME }}, ${{ secrets.SERVER_HOST }}, etc...) that we must define in the GitHub repository to make them available when the workflow jobs are running.

This table lists the variables to define:

Variable name Description
DOCKERHUB_USERNAME The username of the Docker Hub user
DOCKERHUB_TOKEN The personal token of the Docker Hub user. I show how to generate it in this post
SERVER_HOST The IP address of the server where your application is running on Docker
SERVER_PORT The server SSH port. It is port 22 by default
SERVER_USER The user to connect to the server with
SERVER_KEY The private SSH key of the server

To generate the private SSH key of the server, log into the server, run the command "ssh-keygen" and follow the instructions. The private key is in the folder "$HOME/.ssh/id_rsa".

Run the command cat $HOME/.ssh/id_rsa and copy the content printed in the terminal.

Add the GitHub Actions environment variables

Once you have these values, follow the video below to add the "DOCKERHUB_USERNAME" secret. Repeat it for each environment secret.

0:00
/0:24

Add a GitHub Actions secret on a GitHub repository.

Test the GitHub Actions workflow

Create a commit of the changes, push the changes to the remote repository, and go to the GitHub repository to create a pull request.

Once the pull request is created, scroll to the bottom, and you will see a running GitHub Action named "Backend API workflow"

View GitHub Actions execution on the pull request.
View GitHub Actions execution on the pull request.

Click on the GitHub Actions execution named "Backend API workflow"; on the page displayed, you click on the menu "Summary" located at the top left; you will see the following schema of your GitHub Actions workflow:

View all the jobs of a GitHub Action.
View all the jobs of a GitHub Action.

Only the "project-build" job ran, and the two others are disabled because they will run only on the main branch.

Once the "project-build" job succeeds, merge the pull request to the master and wait for the pipeline to finish. You know it when all the jobs are green, as shown in the below picture:

All the jobs of a GitHub Action succeeded.
All the jobs of a GitHub Action succeeded.

This also means there is a Docker container running on the remote server. In the deployment script, we map the container port to port 4800 on the physical server, which means you can access the application from the Internet by combining the IP address and the port of the server.

The API is running is running on the VPS.
The API is running is running on the VPS.

You have an automated CI/CD pipeline to deploy a Node.js application. Try to edit a change, commit, and push to the main branch, wait for the CI/CD pipeline to finish, and verify the changes are applied in production.

Deploy a Node.js application with PM2 and Nginx
This tutorial shows how to deploy a Node.js application on a VPS using PM2 and do a reverse proxy with Nginx on a subdomain to make the application accessible worldwide.

Wrap up

By automating the deployment of your application with a CI/CD pipeline, you gain the following benefits:

  • Lower time to deliver features.
  • Catching bugs earlier before they reach the end user.
  • Preventing code regression.
  • Give a deterministic process of deploying the application in production.

GitHub Actions gives you everything you need to build robust, flexible CI pipelines. To go further with it, check out the documentation and the marketplace.

You can find the code source on the GitHub repository.

Follow me on Twitter or subscribe to my newsletter to avoid missing the upcoming posts and the tips and tricks I occasionally share.