Building a Custom CI/CD Pipeline for Node.js with GitHub Actions

Build a CI/CD pipeline for Node.js with GitHub Actions: run Jest tests with coverage, then deploy to your server over SSH with dotenv-based configuration.

Building a Custom Node.js CI/CD Pipeline

If you’re a Node.js developer using GitHub for version control and collaboration, you’ve likely encountered issues with automated testing and deployment. Perhaps your test suite is failing intermittently due to external dependencies or network connections, causing delays in catching critical errors. Maybe your deploy process relies on manual intervention, leaving room for human error.

You’ll build a custom Continuous Integration/Continuous Deployment (CI/CD) pipeline that automates testing with Jest, tracks code coverage, and deploys to production using SSH and environment variables configured with dotenv. By the end of this tutorial, you’ll have a robust, automated process that ensures your code is thoroughly tested and deployed quickly and reliably, saving you time and reducing stress.

Setting Up Your Node.js Project for GitHub Actions Integration

To begin building a custom CI/CD pipeline with GitHub Actions and Node.js, you’ll first need to set up your project to integrate seamlessly with GitHub Actions.

Create a new Node.js project using the npm init command:

npm init -y

This will generate a basic package.json file. GitHub Actions such as actions/setup-node aren’t installed through npm — you reference them in your workflow file with the uses: keyword. What you do need to install locally is Jest, the test runner your pipeline will call:

npm install --save-dev jest

Next, plan for a workflow file named .github/workflows/node-build-deploy.yml (we’ll create it in the next section). For now, let’s focus on setting up our project.

In the package.json file, add a script that will trigger your GitHub Actions pipeline. Add the following line under the scripts object:

"scripts": {
  "ci-cd": "gh workflow run node-build-deploy.yml"
}

This script uses the GitHub CLI (gh workflow run) to manually trigger the node-build-deploy.yml workflow file. For it to work, the gh CLI must be installed and authenticated (gh auth login), and the workflow must include a workflow_dispatch trigger (we add one in the section on triggers below). Pushes will trigger the pipeline automatically, but having a manual trigger is a handy step in preparing your project for our custom CI/CD pipeline.

Make sure to commit these changes and push them to your GitHub repository so that you can see the magic happen with GitHub Actions. In the next section, we’ll dive into creating a custom workflow file in .github/workflows to automate testing and deployment.

Creating a Custom Workflow File in .github/workflows

Now that you have Node.js set up for GitHub Actions integration, it’s time to create a custom workflow file. This file will define the series of actions that we want to perform when our code is pushed to the repository.

First, create a new directory called .github/workflows at the root of your project. Then, inside this directory, create a new YAML file with a name like node-build-deploy.yml. I’ll be using this example filename throughout this section, but you can choose any name as long as it follows GitHub’s naming conventions.

Here’s an example of what the node-build-deploy.yml file might look like:

name: Node.js Build and Deploy

on:
  push:
    branches:
      - main

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v7

      - name: Install dependencies
        run: npm install

      - name: Build and deploy
        run: |
          npm run build
          ssh user@host 'mkdir -p /path/to/deploy'
          scp -r ./dist/* user@host:/path/to/deploy

This workflow file defines a single job called build-and-deploy that runs on the latest version of Ubuntu. The job performs three steps: checking out the code, installing dependencies, and building and deploying the application.

Note that this is just a basic example to get you started: it assumes your package.json has a build script that outputs to dist, and that the runner already has SSH access to your server. Later in this tutorial we’ll replace the raw ssh/scp commands with dedicated actions that read the SSH key from GitHub secrets. You can customize the workflow file to fit your specific needs by adding more jobs, steps, or using different actions from the GitHub Marketplace.

Configuring Node.js Environment Variables and Dependencies

In our custom pipeline, we need to make sure that our application’s dependencies are installed correctly and that environment variables are set up properly for testing and deployment.

Let’s start by defining our dependencies in the package.json file:

{
    "name": "my-node-app",
    "version": "1.0.0",
    "description": "",
    "main": "index.js",
    "scripts": {
        "test": "jest"
    },
    "keywords": [],
    "author": "",
    "license": "MIT",
    "dependencies": {
        "dotenv": "^16.0.1",
        "express": "^4.20.0"
    },
    "devDependencies": {
        "@types/node": "^18.4.0",
        "jest": "^29.7.0"
    }
}

Next, we need to tell our pipeline how to install dependencies and set environment variables. We’ll add a step in our workflow file (node-build-deploy.yml) that installs dependencies using npm or yarn:

name: Node.js Build and Deploy

on:
  push:
    branches: [ main ]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v7
      - name: Setup node and npm
        uses: actions/setup-node@v7
        with:
          node-version: 20
          cache: npm
      - name: Install dependencies
        run: npm ci

Note that we’re using npm in this example, but you can replace it with yarn if your project uses yarn.

Automating Testing with Jest and Covering Code Coverage

Now that our pipeline can build our project, let’s add a test phase using Jest, a popular JavaScript testing framework.

First, add Jest and the TypeScript tooling as development dependencies in your package.json (or run npm install --save-dev jest ts-jest typescript):

"devDependencies": {
    "jest": "^29.7.0",
    "ts-jest": "^29.1.0",
    "typescript": "^5.4.0"
},

Then, create a new file called jest.config.js to configure Jest:

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  setupFilesAfterEnv: ['<rootDir>/tests/setupTests.ts'],
};

Next, create a tests folder in your project root with the following structure:

app/
src/
...
tests/
setupTests.ts
test-*.spec.ts

The setupTests.ts file can start out empty; use it later for code that should run before every test file. The example test below imports a small function from src/math.ts:

// src/math.ts
export function add(a: number, b: number): number {
  return a + b;
}

In each test file, start writing Jest tests using the describe, it, and expect functions, imported from @jest/globals so TypeScript knows their types. For example:

// tests/test-example.spec.ts
import { describe, expect, it } from '@jest/globals';
import { add } from '../src/math';

describe('math', () => {
  it('adds two numbers correctly', () => {
    const result = add(2, 3);
    expect(result).toBe(5);
  });
});

Finally, update your GitHub Actions workflow to run Jest tests. Add the following code block in your node-build-deploy.yml file:

- name: Run Tests
  run: |
    npm test

Run the pipeline, and you’ll see your Jest tests passing. If you want to cover code coverage, there’s nothing extra to install — Jest has coverage reporting built in. Just pass the --coverage flag in your workflow:

- name: Run Code Coverage
  run: |
    npm run test -- --coverage

This will display the code coverage report in your pipeline output.

Deploying to Production using SSH and dotenv Configuration

Now that our tests are passing, it’s time to deploy our application to production. We’ll use SSH to connect to our server and copy the latest code changes.

First, let’s add deployment steps to the end of the job in our workflow file (node-build-deploy.yml). Store your server’s host, SSH username, and private key as the repository secrets SSH_HOST, SSH_USERNAME, and SSH_PRIVATE_KEY (Settings > Secrets and variables > Actions):

name: Node.js Build and Deploy

on:
  push:
    branches:
      - main

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      # ... (checkout, setup-node, install and test steps from above)

      - name: Copy code to production server
        uses: appleboy/scp-action@v1.0.0
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USERNAME }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          source: "./*"
          target: /var/www/app

      - name: Install dependencies on the server
        uses: appleboy/ssh-action@v1.2.0
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USERNAME }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /var/www/app
            npm ci --omit=dev

In the above code, we use the appleboy/scp-action action to copy the code over SSH, and the appleboy/ssh-action action to run commands on the server. Each action opens its own SSH connection, so both receive the SSH host, username, and private key, which we store as GitHub secrets.

Next, let’s update our .env file on the production server to include the necessary environment variables:

# .env (production)

PORT=3000
DB_HOST=localhost
DB_USERNAME=root
DB_PASSWORD=password

# dotenv configuration (loaded with require('dotenv').config())
NODE_ENV=production

Make sure to replace 3000, localhost, and other placeholders with your actual production settings. Keep this file only on the server (and in .gitignore), so it is never committed or overwritten by a deployment. Then load it at the very top of your application’s entry file with dotenv:

// index.js
require('dotenv').config();

const express = require('express');
const app = express();

app.listen(process.env.PORT || 3000, () => {
  console.log(`Server listening on port ${process.env.PORT || 3000}`);
});

With these changes, our pipeline will now deploy the latest code changes to our production server using SSH. This concludes our custom CI/CD pipeline setup!

Triggering the Pipeline on Push Events and Scheduling Tasks

To automate testing and deployment, we need to trigger our pipeline whenever changes are pushed to our repository. GitHub Actions provides a built-in push event that can be used to trigger workflows.

Let’s update our workflow file (node-build-deploy.yml) to include this event:

name: Node.js Build and Deploy

on:
  push:
    branches:
      - main
  workflow_dispatch:

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      # ... (rest of the workflow remains the same)

In this example, we’re telling GitHub Actions to trigger the Node.js Build and Deploy workflow whenever a change is pushed to the main branch. The workflow_dispatch trigger also lets you start it manually from the Actions tab or with the npm run ci-cd script we added earlier. You can modify the branches setting to include multiple branches if needed.

Scheduling tasks in our pipeline is also possible using the schedule keyword. For instance, let’s assume we want to run a daily report on our application at 2 AM:

on:
  push:
    branches:
      - main
  schedule:
    - cron: '0 2 * * *'

Here, we’re telling GitHub Actions to trigger the Node.js Build and Deploy workflow every day at 2 AM (scheduled workflows always use UTC and run on the default branch). The cron expression is used to specify the time and frequency of the task.

With these settings in place, our pipeline will now automatically run whenever changes are pushed or scheduled tasks occur. In the final section, we’ll cover debugging and troubleshooting techniques for our custom CI/CD pipeline.

Debugging and Troubleshooting Your Custom CI/CD Pipeline

Debugging and troubleshooting are crucial steps in ensuring your custom CI/CD pipeline runs smoothly. GitHub Actions provides several features to help you identify issues.

Firstly, enable debug logging for the workflow by creating a repository secret or variable named ACTIONS_STEP_DEBUG with the value true (you can also re-run a failed job with “Enable debug logging” checked). This adds detailed debug output to the logs of each step in the workflow.

name: Custom Workflow

on:
  push:
    branches: [ main ]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v7
      - name: Run tests
        run: npm test -- --verbose

In addition, GitHub provides an actions/github-script@v9 action that allows you to run JavaScript within the workflow, with the workflow context and the core toolkit available. You can use this to log debug messages or gather information about your environment.

For example:

steps:
  - name: Log debug message
    uses: actions/github-script@v9
    with:
      script: |
        core.debug(`Event: ${context.eventName}, ref: ${context.ref}`);
        core.info(`Workflow run ID: ${context.runId}`);

Finally, if you’re experiencing issues with your pipeline, ensure you have the correct permissions and configuration in place. GitHub Actions provides a built-in debugging tool that allows you to view the workflow’s output.

By following these steps, you’ll be able to identify and fix issues in your custom CI/CD pipeline, ensuring smooth deployments and continuous integration. With this tutorial complete, you now have a solid foundation for automating your Node.js project using GitHub Actions.

Frequently Asked Questions

What is the purpose of adding a script to the package.json file for triggering GitHub Actions pipeline?

The script uses the GitHub CLI (gh workflow run) to manually trigger the node-build-deploy.yml workflow file, which requires a workflow_dispatch trigger in that workflow. It’s a convenient way to re-run your custom CI/CD pipeline without pushing a new commit.

Why do I need to commit and push changes to see the magic happen with GitHub Actions?

Committing and pushing changes allows GitHub Actions to detect the changes and trigger the pipeline, enabling you to see the automation in action.

What is a common error or pitfall when setting up a CI/CD pipeline with GitHub Actions?

A common mistake is referencing something the workflow can’t find, such as a workflow file name that doesn’t match the one in .github/workflows, a manual trigger without a workflow_dispatch event, or SSH secrets that were never added to the repository. These cause the pipeline to fail or never start, delaying testing and deployment.

How does this approach compare to using an alternative tool like Travis CI or CircleCI?

This approach uses GitHub Actions, which integrates seamlessly with your repository on GitHub, whereas tools like Travis CI or CircleCI require separate accounts and setup processes.

What is the purpose of creating a custom workflow file in .github/workflows?

The custom workflow file defines the series of actions to perform when code is pushed to the repository, allowing for automation of testing and deployment.

Comments

comments