Build a CI/CD with GitHub Actions to Deploy a Node.js API
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:
Prerequisites
To follow this tutorial, make sure you have the following tools installed on your computer.
- Git for code versioning - Download link
- The Docker Engine - Download link
- A free GitHub account - Create an account
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:
- 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.
- 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:
- 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.
- Build the Docker image and publish it to the Docker Hub registry.
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:
- It depends on the "docker-build" job since we cannot deploy anything if no new image is published to the Docker Hub.
- 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?
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.
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"
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:
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:
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.
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.
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.