Build a CI/CD Pipeline with GitHub Actions
Automate testing for a Node.js API using GitHub Actions workflows.
Introduction
30 Second Summary
Imagine pushing a code change and finding out days later that it broke something. That is how bugs sneak into production when teams rely on manual testing.
In this project, you will build an automated CI/CD pipeline using GitHub Actions that runs your tests every time you push code. You will experience the full developer workflow of branching, breaking, fixing, and merging with confidence.
What You'll Build
You will build a GitHub Actions workflow that automatically tests a Node.js Express API on every push and pull request.
By the end of this project, you'll have:
- A Node.js Express API with two endpoints and an automated test suite.
- A GitHub Actions workflow file that runs your tests on every push and pull request.
- A pull request demonstrating CI catching a failing test, then passing after your fix.
- Secret Mission: Supercharge your pipeline with dependency caching and a multi-version build matrix.
Are there any prerequisites?
You should have basic familiarity with Git commands like clone, push, and branch. You also need a GitHub account and Node.js with npm installed on your machine.
Before We Start
Before We Start...
Set Up the Node.js Project
Before you can build a CI pipeline, you need a project for it to test. A pipeline without code is like a security guard with nothing to protect.
In this step, you'll create a small API with Express and write automated tests using Jest. This gives your pipeline real code to validate on every push.
In this step, get ready to:
- Initialize a Node.js project and install dependencies.
- Create an Express API with two endpoints.
- Write and run automated tests for the API.
Initialize the project and install dependencies
You need a dedicated folder for your project and a few packages installed before you can write any code.
Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar. Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir ci-pipeline-demo
cd ci-pipeline-demo
- Confirm the folder exists by running:
ls
The folder is empty for now. That is expected.
- Initialize a Node.js project and install all the dependencies by running these commands:
npm init -y
npm install express@5.2.1
npm install --save-dev jest@30.3.0 supertest@7.2.2
What do these commands do?
- npm init -y creates a package.json file with default values. This file tracks your project's metadata and dependencies.
- npm install express@5.2.1 installs Express, the framework your API is built on.
- npm install --save-dev installs Jest (the testing framework) and Supertest (a library for testing HTTP endpoints) as development-only dependencies.
- Confirm the install succeeded by running:
ls
You should see node_modules, package.json, and package-lock.json in the output.
Seeing 'npm: command not found'?
This means Node.js is not installed or not on your system PATH. Visit nodejs.org to download and install the latest LTS version. Close and reopen your terminal after installing, then try again.
Help me troubleshoot my npm installation.
Now you need to open the project in a code editor so you can create and edit files.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the ci-pipeline-demo folder on your Desktop, then click Open.
The package.json file that npm init created has a default test script that does nothing useful. You need to replace it so that npm test actually runs Jest.
- In the Explorer sidebar in VS Code, click package.json to open it.
- Find the "scripts" section. It looks like this:
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
}
- Replace the entire "scripts" block (including the curly braces) with this:
"scripts": {
"test": "jest",
"start": "node src/server.js"
}
- Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
What did you just change?
- "test": "jest" tells npm test to run the Jest test runner.
- "start": "node src/server.js" tells npm start to launch your server file.
Create the Express API
Your API needs two files. One defines the app and its routes. The other starts the server.
Why two separate files?
Separating the app definition (app.js) from the server startup (server.js) lets your test files import the app directly without opening a real network port.
This is a standard pattern in production Express projects. It keeps tests fast and avoids port conflicts.
- Create a src folder inside ci-pipeline-demo. In the Explorer sidebar, hover over the project name and click the New Folder icon.
- Type src and press Enter.
- Hover over the src folder and click the New File icon.
- Type app.js and press Enter.
- Paste the following code into app.js:
const express = require('express');
const app = express();
// Return a welcome message at the root URL
app.get('/', (req, res) => {
res.send('Welcome to the CI/CD Demo API!');
});
// Return a JSON health check response
app.get('/health', (req, res) => {
res.json({ status: 'healthy' });
});
// Export the app so tests can import it without starting a server
module.exports = app;
What does this code do?
- require('express') imports the Express framework, and express() creates a new app instance.
- app.get('/', ...) defines a route that responds to GET requests at the root URL with a plain text welcome message.
- app.get('/health', ...) defines a health check endpoint that returns a JSON object. Health endpoints are standard in production APIs for monitoring.
- module.exports = app makes the app available to other files. Your tests and server file both import it.
- Save src/app.js.
You won't see a visible change yet. The next file starts the server, and the tests in the following substep verify everything works.
- Hover over the src folder again and click the New File icon.
- Type server.js and press Enter.
- Paste the following code into server.js:
const app = require('./app');
const port = 3000;
// Start the server and log which port it is running on
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
What does this code do?
- require('./app') imports the app you just defined in app.js.
- app.listen(port, ...) starts the server on port 3000 and prints a confirmation message to the console.
- Save src/server.js.
You won't see a change yet. The tests in the next substep confirm both files are working correctly.
✔️ Awesome, I've got everything!
Double check you've saved both files before moving on.
ⓧ I'd like to double check the full code
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.send('Welcome to the CI/CD Demo API!');
});
app.get('/health', (req, res) => {
res.json({ status: 'healthy' });
});
module.exports = app;
const app = require('./app');
const port = 3000;
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
Write the test suite
Your CI pipeline will run these tests automatically every time you push code. If a test fails, the pipeline blocks the change from merging.
- In the Explorer sidebar, hover over the project name (ci-pipeline-demo) and click the New Folder icon.
- Type __tests__ (two underscores on each side) and press Enter.
- Hover over the __tests__ folder and click the New File icon.
- Type app.test.js and press Enter.
- Paste the following code into app.test.js:
const request = require('supertest');
const app = require('../src/app');
describe('API Endpoints', () => {
// Test that the root URL returns the welcome message
test('GET / returns welcome message', async () => {
const response = await request(app).get('/');
expect(response.status).toBe(200);
expect(response.text).toBe('Welcome to the CI/CD Demo API!');
});
// Test that the health endpoint returns a JSON status
test('GET /health returns healthy status', async () => {
const response = await request(app).get('/health');
expect(response.status).toBe(200);
expect(response.body).toEqual({ status: 'healthy' });
});
});
What does this code do?
- require('supertest') imports Supertest, which lets you send HTTP requests to your Express app without starting a real server.
- request(app).get('/') simulates a GET request to the root URL and captures the response.
- expect(response.status).toBe(200) checks that the server returned a 200 OK status code.
- expect(response.body).toEqual({ status: 'healthy' }) verifies the health endpoint returns the exact JSON object your API promises.
- Save __tests__/app.test.js.
✔️ Awesome, I've got everything!
Double check you've saved the file before running the tests.
ⓧ I'd like to double check the full code
const request = require('supertest');
const app = require('../src/app');
describe('API Endpoints', () => {
test('GET / returns welcome message', async () => {
const response = await request(app).get('/');
expect(response.status).toBe(200);
expect(response.text).toBe('Welcome to the CI/CD Demo API!');
});
test('GET /health returns healthy status', async () => {
const response = await request(app).get('/health');
expect(response.status).toBe(200);
expect(response.body).toEqual({ status: 'healthy' });
});
});
Now it is time to verify that everything works together.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, top-left of your keyboard).
- Run the test suite by running this command:
npm test
You should see output showing both tests passing with green checkmarks. Jest displays something like Tests: 2 passed, 2 total.
Tests failing or seeing an error?
- If you see Cannot find module, make sure your files are in the correct folders. app.js and server.js must be inside src/, and app.test.js must be inside __tests__/.
- If you see jest: not found, check that the "test" script in package.json says "jest" and that you saved the file.
- If a test fails with a mismatch, compare your strings carefully. The welcome message must be exactly Welcome to the CI/CD Demo API! in both app.js and the test file.
Help me debug my failing Jest tests.
Your Node.js project is ready with a working API and a passing test suite. Next up, you'll write a GitHub Actions workflow that runs these tests automatically every time you push code.
Create Your GitHub Actions Workflow
Your Express API has two endpoints and a passing test suite. Now you need a way to run those tests automatically every time someone changes the code.
GitHub Actions is a CI/CD platform built directly into GitHub. In this step, you'll write a YAML workflow file that tells GitHub to run your tests on every push and pull request.
In this step, get ready to:
- Create the .github/workflows/ directory that GitHub Actions requires.
- Write a workflow YAML file with push and pull request triggers.
- Understand key concepts like triggers, jobs, and pre-built actions.
Create the workflow directory and file
GitHub Actions looks for workflow files in a specific folder inside your repository. Any .yml file placed in .github/workflows/ is automatically recognized as a workflow definition.
- Create the required directory inside your ci-pipeline-demo folder by running this command:
macOS
mkdir -p .github/workflows
Windows
mkdir .github\workflows
- Confirm the directory was created by running:
ls .github
You should see workflows listed in the output.
Don't see the workflows folder?
On Windows Command Prompt, make sure you used backslashes: mkdir .github\workflows.
If you see a File exists error, that means the folder is already there. You can continue to the next substep.
Help me create the .github/workflows directory for GitHub Actions
Now you need to create the workflow file itself inside that directory.
- In VS Code, find the .github folder in the Explorer sidebar on the left and expand it to see workflows.
- Right-click the workflows folder and click New File.
- Type ci.yml and press Enter.
An empty ci.yml file opens in the editor. You'll fill it with your workflow definition next.
Write the workflow YAML
Your workflow file needs three things: a name, a set of triggers that determine when it runs, and a job that defines what it does.
- In ci.yml, paste the following code:
# Display name shown in the GitHub Actions tab
name: CI Pipeline
# Triggers: when should this workflow run?
on:
# Run on every push to any branch
push:
branches:
- '**'
# Run on pull requests targeting main
pull_request:
branches:
- main
jobs:
# Define a job called 'test'
test:
# Use a GitHub-hosted Ubuntu virtual machine
runs-on: ubuntu-latest
steps:
# Download your repository code
- name: Check out code
uses: actions/checkout@v6
# Install Node.js on the runner
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 22
# Install dependencies using the exact lockfile versions
- name: Install dependencies
run: npm ci
# Execute the test suite
- name: Run tests
run: npm test
What does this workflow do?
- name: CI Pipeline sets the display name you'll see in the GitHub Actions tab.
- The on section defines two triggers. The workflow runs on every push to any branch and on every pull_request targeting main.
- The jobs section defines a single job called test that checks out your code, installs Node.js, installs dependencies, and runs your test suite.
- Save ci.yml.
You won't see any output yet. GitHub Actions workflows only execute after the code is pushed to a GitHub repository, which you'll do in the next step.
YAML looking messy in your editor?
YAML is strict about indentation. Every level of nesting must use exactly 2 spaces, not tabs. Make sure the steps list items each start with a - character followed by a space.
If your editor uses tabs instead of spaces, press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) to open the Command Palette. Type Indent Using Spaces and select it to switch to spaces.
Help me fix the indentation in my GitHub Actions YAML workflow file
✔️ Awesome, I've got everything!
Great. Double check you've saved ci.yml before moving on.
ⓧ I'd like to double check the full code
Here's the complete ci.yml file. Make sure yours matches exactly.
name: CI Pipeline
on:
push:
branches:
- '**'
pull_request:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 22
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
Understand the key workflow concepts
Before you push this workflow to GitHub, let's unpack a few key concepts in the YAML you just wrote.
How do workflow triggers work?
The push trigger runs the workflow every time someone pushes commits to any branch. The '**' wildcard pattern matches all branch names.
The pull_request trigger runs when a pull request is opened or updated against the main branch. This is how CI catches bugs before they get merged.
The test job defines four steps that run inside a fresh virtual machine every time the workflow triggers.
What happens inside the test job?
runs-on: ubuntu-latest tells GitHub to spin up a fresh Linux virtual machine for each workflow run. Every run starts clean with no leftover files from previous runs.
actions/checkout@v6 and actions/setup-node@v6 are pre-built actions maintained by GitHub. They handle common tasks like downloading your code and installing Node.js so you don't have to write that logic yourself.
npm ci is preferred over npm install in CI environments. It installs the exact versions from your package-lock.json file. It is faster because it deletes node_modules first and skips certain checks that npm install performs.
Your workflow file is ready. Next up, you'll push this code to GitHub and watch the pipeline run for the first time.
Push to GitHub and Watch Your Pipeline Run
Your tests pass locally and your workflow file is in place. Now it's time to push everything to GitHub where the pipeline can do its job.
GitHub Actions automatically detects workflow files inside the .github/workflows/ directory. The moment your code lands on GitHub, it reads ci.yml and kicks off your first pipeline run.
In this step, get ready to:
- Create a public GitHub repository and push your project code.
- Watch your CI pipeline run automatically and verify all tests pass.
Create a GitHub repository and push
You need a remote repository on GitHub to host your code. GitHub Actions only runs when the workflow file exists in a GitHub repository.
- Open your browser and go to github.com.
- Click the + icon in the top-right corner of the page.
- Select New repository from the dropdown.
- Enter ci-pipeline-demo as the Repository name.
- Select Public for visibility.
- Leave all initialization options unchecked. Do not add a README, .gitignore, or license file.
- Click Create repository.
Why leave initialization options unchecked?
You already have files in your local project folder. If GitHub creates a README or .gitignore during repo creation, your first push will fail with a merge conflict because both sides have different initial commits.
- Initialize a local Git repository and push your code to GitHub by running these commands:
git init
git add .
git commit -m "Initial commit: Express API with tests and CI workflow"
git branch -M main
git remote add origin https://github.com/[[YOUR_USERNAME="your GitHub username"]]/ci-pipeline-demo.git
git push -u origin main
What do these commands do?
- git init creates a new Git repository in your current folder.
- git add . stages all files for your first commit.
- git commit -m "..." saves a snapshot of your code with a descriptive message.
- git branch -M main renames the default branch to main.
- git remote add origin links your local repo to the GitHub repository you just created.
- git push -u origin main uploads your code to GitHub and sets main as the default upstream branch.
GitHub will ask you to sign in
When you run git push, your terminal will prompt you to authenticate with GitHub. Follow the popup or terminal instructions to sign in with your GitHub credentials.
If you've never pushed from this computer before, GitHub may ask you to create a personal access token or sign in through a browser window.
- Go to your repository page on GitHub at https://github.com/YOUR_USERNAME/ci-pipeline-demo.
- You should see all your project files listed, including the src/ folder, __tests__/ folder, and .github/ folder.
Push rejected or authentication failed?
- If you see remote: Repository not found, double-check that your GitHub username is correct in the git remote add origin command and that the repository name matches exactly.
- If authentication fails, GitHub no longer accepts passwords for HTTPS pushes. You may need to create a personal access token in your GitHub settings under Developer settings > Personal access tokens.
- If you see failed to push some refs, you may have accidentally initialized the GitHub repo with a README. Delete the repository on GitHub and recreate it without any initialization options.
Help me troubleshoot my git push to GitHub
Watch the workflow run
Your push automatically triggered the CI pipeline. GitHub detected the ci.yml file and started running the workflow.
- Click the Actions tab at the top of your repository page.
- Click on the workflow run titled Initial commit: Express API with tests and CI workflow.
- Click on the test job to expand its step-by-step output.
You should see four steps: checkout, setup Node.js, install dependencies, and run tests. A green checkmark next to each step means it succeeded.
What's happening behind the scenes?
GitHub spun up a fresh Ubuntu Linux virtual machine just for this run. It checked out your code, installed Node.js 22, ran npm ci to install your packages from package-lock.json, and then ran npm test.
The entire process takes about 30 to 60 seconds. Every run gets its own clean VM, so there's no leftover state from previous runs.
Don't see a workflow run?
- Make sure your ci.yml file is inside the .github/workflows/ directory. GitHub only detects workflow files in this exact path.
- Check for YAML syntax errors by looking for a red banner on the Actions tab. Even a single extra space or missing colon can break the workflow file.
- Verify that your push actually reached GitHub. Check the repository's main page for your files.
Help me figure out why my GitHub Actions workflow isn't running
Your CI pipeline is live and passing. Next up, you'll test what happens when someone pushes code that breaks the tests.
Simulate a Real PR Workflow with a Failing Test
Your pipeline is live on GitHub and all tests are passing. You've confirmed that the CI workflow triggers on a push and runs to completion.
In a real team, the most valuable moment for CI is when it catches a bug before it reaches production. In this step, you'll simulate that exact scenario by pushing a breaking change, watching the pipeline flag it, and fixing the code so CI turns green again.
In this step, get ready to:
- Create a feature branch with a code change that breaks an existing test.
- Open a pull request and watch the CI pipeline catch the failure.
- Fix the test and push again to see CI pass.
Create a feature branch with a breaking change
In professional workflows, developers create a separate branch for each feature or bug fix. This keeps the main branch stable while work is in progress.
- Create a new branch called feature/update-health by running this command:
git checkout -b feature/update-health
Now you'll simulate a developer updating the health endpoint to return a different status value. The existing test expects 'healthy', so changing that value will cause a test failure.
- Open src/app.js in your text editor.
- Find this line inside the /health route:
res.json({ status: 'healthy' });
- Replace 'healthy' with 'ok', so the line now looks like this:
res.json({ status: 'ok' });
- Save src/app.js.
- Commit your change and push the branch to GitHub by running these commands:
git add .
git commit -m "Update health endpoint status value"
git push -u origin feature/update-health
You should see output confirming the branch was pushed to GitHub, including a new remote tracking branch reference.
Push failed?
- Make sure you're on the correct branch by running git branch. The active branch has an asterisk next to it.
- If you see a permissions error, double check that the remote URL matches your GitHub repository by running git remote -v.
- Why is my git push to feature/update-health failing?
Open a pull request and watch CI fail
A pull request is how developers propose changes to the main branch. When you open one, GitHub Actions automatically runs your CI pipeline against the proposed changes.
- Go to your ci-pipeline-demo repository on GitHub.
- Look for a yellow banner near the top of the page suggesting you create a pull request for your recently pushed branch.
- Click Compare & pull request.
- Enter a title for your pull request, such as Update health endpoint status value.
- Click Create pull request.
Scroll down on the pull request page to the checks section. You'll see the CI workflow running automatically.
- Wait about a minute for the workflow to complete.
- You should see a red X indicating the tests failed.
- Click Details next to the failed check to see the full job log.
Why did the test fail?
You changed the health endpoint to return { status: 'ok' }, but the test still expects { status: 'healthy' }. The values don't match, so Jest reports a failure.
This is exactly what CI is designed to catch. The pipeline flagged a mismatch between your code and its tests before the change could reach the main branch.
Fix the test and watch CI pass
The 'ok' value is the intended new behavior for the health endpoint. To make CI pass, you need to update the test to expect the new value.
- Open __tests__/app.test.js in your text editor.
- Find the second test (GET /health returns healthy status).
- Find this line:
expect(response.body).toEqual({ status: 'healthy' });
- Replace 'healthy' with 'ok', so the line now looks like this:
expect(response.body).toEqual({ status: 'ok' });
- Save __tests__/app.test.js.
- Commit the fix and push it to the same branch by running these commands:
git add .
git commit -m "Update test to match new health status value"
git push
- Go back to your pull request page on GitHub.
- A new CI run starts automatically when the push arrives.
- Wait for the workflow to complete. You should see a green checkmark indicating all tests passed.
Your pull request is now safe to merge. The CI pipeline verified that your code and tests are in sync.
Still seeing a red X?
- Make sure you saved __tests__/app.test.js before committing. Unsaved changes won't be included in the commit.
- Verify that src/app.js has 'ok' and __tests__/app.test.js also has 'ok'. Both files must agree on the status value.
- Run npm test locally to check if the tests pass on your machine before pushing again.
- Why is my CI still failing after updating the test?
✔️ Awesome, I've got everything!
Double check that both src/app.js and __tests__/app.test.js are saved with the updated 'ok' value.
ⓧ I'd like to double check the full code
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.send('Welcome to the CI/CD Demo API!');
});
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
module.exports = app;
const request = require('supertest');
const app = require('../src/app');
describe('API Endpoints', () => {
test('GET / returns welcome message', async () => {
const response = await request(app).get('/');
expect(response.status).toBe(200);
expect(response.text).toBe('Welcome to the CI/CD Demo API!');
});
test('GET /health returns healthy status', async () => {
const response = await request(app).get('/health');
expect(response.status).toBe(200);
expect(response.body).toEqual({ status: 'ok' });
});
});
Secret mission
Add Caching and a Build Matrix
Your CI pipeline runs tests automatically. But it re-downloads every dependency each time, and only tests on a single Node.js version. Time to fix both of those.
Before you go
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses a public GitHub repository with free Actions minutes, so there are no ongoing costs.
Resources you used:
- Local ci-pipeline-demo project directory with Node.js files and dependencies.
- GitHub repository ci-pipeline-demo (public).
- GitHub Actions workflow (.github/workflows/ci.yml) running on every push and pull request.
Keep everything running
No action needed. Choose this if you plan to keep building on your CI/CD pipeline or want to reference it later.
- Your GitHub repository stays public and free. GitHub Actions will continue to run on every push and pull request at no cost.
- Your local project files remain on your Desktop in the ci-pipeline-demo folder.
Pause - I'll come back to this later
Disable the workflow so it stops running automatically, but keep everything intact for when you return.
- Go to your repository on GitHub and click the Actions tab.
- Click CI Pipeline in the left sidebar to select your workflow.
- Click the ... menu (three dots) in the top right and select Disable workflow.
- When you come back, use the same menu to select Enable workflow.
Delete - I don't want to use this again
Remove all project resources and start fresh.
- Delete the GitHub repository by going to Settings at the bottom of your repository page.
- Scroll down to the Danger Zone section and click Delete this repository.
- Follow the confirmation prompts to permanently delete the repository.
- Delete the local project folder by removing the ci-pipeline-demo directory from your Desktop.
Mission accomplished
That's a Wrap!
Nice work! You just built a real CI/CD pipeline from scratch using GitHub Actions. Every time you push code or open a pull request, your pipeline automatically runs your test suite and reports whether the changes are safe to merge.
You've learned how to:
- Built a Node.js Express API with automated tests using Jest and Supertest.
- Written a GitHub Actions workflow YAML file from scratch with push and pull request triggers.
- Simulated a full developer workflow: created a branch, opened a pull request, watched CI catch a failing test, fixed it, and merged with a green checkmark.
- Supercharged your pipeline with dependency caching and a build matrix to test across multiple Node.js versions.
Ready to quiz yourself?