Build a CI/CD Pipeline with GitHub Actions

Automate testing and Docker image publishing with GitHub Actions.

Introduction

30 Second Summary

Every time a developer pushes code without automated checks, they risk breaking things for the entire team. At top engineering companies, code never reaches production without passing through an automated pipeline first.

In this project, you will build a production-grade CI/CD pipeline using GitHub Actions that automatically lints, tests, and containerizes a Node.js API on every push.

What You'll Build

A multi-job CI/CD pipeline for an Express.js REST API that lints code, tests across multiple Node.js versions, builds a Docker image, and publishes it to GitHub Container Registry.

By the end of this project, you'll have:

  • A working Node.js REST API with automated tests using Jest and linting using ESLint.
  • A Dockerfile that containerizes your API into a portable, production-ready image.
  • A multi-job GitHub Actions workflow with matrix testing across Node.js 22 and 24, plus automatic Docker image publishing.
  • Secret Mission: Add a test coverage gate that fails the pipeline if coverage drops below 80%.

Are there any prerequisites?

You should have a GitHub account, Git, Node.js, and Docker installed on your machine. Basic JavaScript knowledge is helpful but not required.

Before We Start

Before We Start...

Set Up the Node.js API Project

Every CI/CD pipeline needs something to build, test, and ship. In this project, that something is a simple REST API built with Express.

In this step, you will create the Node.js project from scratch, write the API code with two endpoints, and write automated tests that prove both endpoints work correctly.

In this step, get ready to:
  • Initialize a Node.js project and install dependencies.
  • Write an Express API with health check and status endpoints.
  • Configure npm scripts and verify tests pass locally.
Create the project and install dependencies

You need a dedicated folder for your project and a package.json file that tracks your dependencies and scripts.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir ci-cd-api
cd ci-cd-api
  • Initialize the Node.js project by running:
npm init -y

What does this command do?

The -y flag tells npm to skip the interactive questionnaire and generate a default package.json with sensible defaults. It sets the project name to your folder name (ci-cd-api) and version to 1.0.0.

  • Install Express as a production dependency by running:
npm install express
  • Install Jest, supertest, and ESLint as development dependencies by running:
npm install --save-dev jest supertest eslint

Why separate dependency types?

Production dependencies (dependencies) are packages your app needs to run. Dev dependencies (devDependencies) are only needed during development and testing.

When you build your Docker container later, you will skip dev dependencies to keep the image small and secure.

  • Create the source and test directories by running:
mkdir src tests
  • Confirm the files and folders exist by running:
ls

You should see node_modules, package.json, package-lock.json, src, and tests in the output.

Seeing permission errors or command not found?

Make sure Node.js is installed by running node --version in your terminal. If you see a version number, Node is ready.

If npm install fails with permission errors, try closing and reopening your terminal.

Help me troubleshoot my npm install errors.

Write the Express API and test file

Your API needs two endpoints: a health check that confirms the server is running, and a status endpoint that returns service information. You will also write tests that verify both endpoints.

  • In VS Code, open the ci-cd-api folder on your Desktop.
  • Create a new file called app.js inside the src folder.
  • Paste the following code into src/app.js:
const express = require("express");
const app = express();

// Health check endpoint - confirms the server is running
app.get("/health", (req, res) => {
  res.status(200).json({ status: "healthy", timestamp: new Date().toISOString() });
});

// Status endpoint - returns service information
app.get("/api/status", (req, res) => {
  res.status(200).json({
    service: "ci-cd-api",
    version: "1.0.0",
    uptime: process.uptime(),
  });
});

module.exports = app;

What does this code do?

This file defines your Express application without starting the server. Separating the app definition from the server startup lets your tests import the app directly without spinning up a live server.

The /health endpoint returns a JSON object with a "healthy" status and a timestamp. The /api/status endpoint returns service metadata including the uptime in seconds.

  • Save src/app.js.
  • Create a new file called index.js inside the src folder.
  • Paste the following code into src/index.js:
const app = require("./app");

// Set port from environment variable or default to 3000
const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

What does this code do?

This file imports the app from app.js and starts the server on port 3000. It reads the port from an environment variable first, which is important for running inside a container later.

  • Save src/index.js.
  • Create a new file called app.test.js inside the tests folder.
  • Paste the following code into tests/app.test.js:
const request = require("supertest");
const app = require("../src/app");

// Test the health check endpoint
describe("GET /health", () => {
  it("should return 200 and healthy status", async () => {
    const res = await request(app).get("/health");
    expect(res.status).toBe(200);
    expect(res.body.status).toBe("healthy");
    expect(res.body.timestamp).toBeDefined();
  });
});

// Test the status endpoint
describe("GET /api/status", () => {
  it("should return 200 and service info", async () => {
    const res = await request(app).get("/api/status");
    expect(res.status).toBe(200);
    expect(res.body.service).toBe("ci-cd-api");
    expect(res.body.version).toBe("1.0.0");
    expect(res.body.uptime).toBeDefined();
  });
});

What does this code do?

Each describe block groups tests for a single endpoint. The supertest library sends HTTP requests directly to your Express app without starting an actual server.

The tests check that each endpoint returns a 200 status code and the expected JSON properties. These are the assertions your CI pipeline will run on every push.

  • Save tests/app.test.js.

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the full code for each file you just created:

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

app.get("/health", (req, res) => {
  res.status(200).json({ status: "healthy", timestamp: new Date().toISOString() });
});

app.get("/api/status", (req, res) => {
  res.status(200).json({
    service: "ci-cd-api",
    version: "1.0.0",
    uptime: process.uptime(),
  });
});

module.exports = app;
const app = require("./app");

const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
const request = require("supertest");
const app = require("../src/app");

describe("GET /health", () => {
  it("should return 200 and healthy status", async () => {
    const res = await request(app).get("/health");
    expect(res.status).toBe(200);
    expect(res.body.status).toBe("healthy");
    expect(res.body.timestamp).toBeDefined();
  });
});

describe("GET /api/status", () => {
  it("should return 200 and service info", async () => {
    const res = await request(app).get("/api/status");
    expect(res.status).toBe(200);
    expect(res.body.service).toBe("ci-cd-api");
    expect(res.body.version).toBe("1.0.0");
    expect(res.body.uptime).toBeDefined();
  });
});
Configure npm scripts and verify everything works

Your package.json still has the default scripts from npm init. You need to replace them with scripts that start your server, run tests, and lint your code.

  • Open package.json in VS Code.
  • Replace the entire contents of the file with the following:
{
  "name": "ci-cd-api",
  "version": "1.0.0",
  "description": "Production-grade CI/CD pipeline demo API",
  "main": "src/index.js",
  "scripts": {
    "start": "node src/index.js",
    "test": "jest",
    "lint": "eslint src/ tests/"
  },
  "keywords": [],
  "author": "",
  "license": "MIT",
  "dependencies": {
    "express": "^5.1.0"
  },
  "devDependencies": {
    "eslint": "^10.5.0",
    "jest": "^30.4.2",
    "supertest": "^7.1.0"
  }
}

What do these scripts do?

The start script launches your server. The test script runs Jest to execute your test files. The lint script runs ESLint against both your source and test directories.

Your CI pipeline will call these exact scripts in later steps. If they pass locally, they will pass in the pipeline.

  • Save package.json.

Next, ESLint needs a configuration file to know which rules to apply.

  • Create a new file called eslint.config.js in the root of your ci-cd-api folder (not inside src or tests).
  • Paste the following code into eslint.config.js:
export default [
  {
    files: ["src/**/*.js", "tests/**/*.js"],
    rules: {
      "no-unused-vars": "error",
      "no-undef": "error",
      semi: ["error", "always"],
    },
    languageOptions: {
      ecmaVersion: 2022,
      sourceType: "module",
      globals: {
        process: "readonly",
        console: "readonly",
        module: "readonly",
        require: "readonly",
        __dirname: "readonly",
        describe: "readonly",
        it: "readonly",
        expect: "readonly",
        beforeAll: "readonly",
        afterAll: "readonly",
      },
    },
  },
];

What does this config do?

This is ESLint's flat config format. It tells ESLint to check all .js files in src/ and tests/ for unused variables, undefined variables, and missing semicolons.

The globals section tells ESLint about Node.js and Jest built-in names so it does not flag them as undefined.

  • Save eslint.config.js.

Now verify that your tests pass locally.

  • Run your tests by executing this command in the terminal:
npm test

You should see output showing 2 passing tests with green checkmarks.

  • Run the linter by executing:
npm run lint

You should see no errors or warnings in the output. A clean lint run produces no output at all.

Tests failing or lint errors?

If npm test fails, check that your tests/app.test.js file imports from "../src/app" (with the correct relative path).

If npm run lint shows errors about undefined variables, make sure your eslint.config.js includes all the globals listed above (process, require, module, describe, it, expect).

Help me fix my failing tests or lint errors.

✔️ Awesome, I've got everything!

Great. Double check that all five files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the full code for the files you created or edited in this substep:

{
  "name": "ci-cd-api",
  "version": "1.0.0",
  "description": "Production-grade CI/CD pipeline demo API",
  "main": "src/index.js",
  "scripts": {
    "start": "node src/index.js",
    "test": "jest",
    "lint": "eslint src/ tests/"
  },
  "keywords": [],
  "author": "",
  "license": "MIT",
  "dependencies": {
    "express": "^5.1.0"
  },
  "devDependencies": {
    "eslint": "^10.5.0",
    "jest": "^30.4.2",
    "supertest": "^7.1.0"
  }
}
export default [
  {
    files: ["src/**/*.js", "tests/**/*.js"],
    rules: {
      "no-unused-vars": "error",
      "no-undef": "error",
      semi: ["error", "always"],
    },
    languageOptions: {
      ecmaVersion: 2022,
      sourceType: "module",
      globals: {
        process: "readonly",
        console: "readonly",
        module: "readonly",
        require: "readonly",
        __dirname: "readonly",
        describe: "readonly",
        it: "readonly",
        expect: "readonly",
        beforeAll: "readonly",
        afterAll: "readonly",
      },
    },
  },
];

Your API is built and tested locally. Next up, you will wrap it in a Docker container so the CI pipeline can build and ship it as a portable image.

Containerize with Docker

Your API is tested and linted. Now you need to package it so it runs identically on any machine, whether that's your laptop, a teammate's computer, or a cloud server.

In this step, you'll wrap the API inside a Docker container. A container bundles your code, its dependencies, and its runtime environment into a single portable unit.

In this step, get ready to:
  • Write a Dockerfile that packages the API into a lightweight Alpine image.
  • Build and run the container locally.
  • Test the containerized API to confirm it responds correctly.
Create the Dockerfile

A Dockerfile is a recipe that tells Docker how to build an image of your app. Each line is an instruction that adds a layer to the image.

  • Create a new file called Dockerfile in the top level of your ci-cd-api folder (not inside src/). Paste the following code into it:
# Use a lightweight Node.js base image
FROM node:22-alpine

# Set the working directory inside the container
WORKDIR /app

# Copy package files first (for better caching)
COPY package*.json ./

# Install only production dependencies
RUN npm ci --only=production

# Copy the application source code
COPY src/ ./src/

# Document which port the app listens on
EXPOSE 3000

# Start the server when the container runs
CMD ["node", "src/index.js"]

What does this Dockerfile do?

  • FROM node:22-alpine starts from a minimal Node.js 22 image. Alpine Linux keeps the final image small (around 50MB instead of 900MB+).
  • COPY package*.json ./ copies both package.json and package-lock.json before copying source code. Docker caches this layer, so dependencies only reinstall when they actually change.
  • RUN npm ci --only=production installs dependencies from the lockfile exactly, skipping dev tools like Jest and ESLint that the container doesn't need.
  • CMD ["node", "src/index.js"] tells Docker what command to run when the container starts.
  • Save Dockerfile.
  • Confirm the file exists by running:
ls Dockerfile

You should see Dockerfile printed in the output.

Don't see the file?

  • Make sure you created the file in the ci-cd-api folder, not inside src/.
  • The filename must be exactly Dockerfile with a capital D and no file extension.

Why isn't Docker finding my Dockerfile?

Add a .dockerignore and build the image

Before building, you need to tell Docker which files to skip. Without a .dockerignore file, Docker would copy everything into the image, including test files and node_modules you don't need.

  • Create a new file called .dockerignore in the top level of your ci-cd-api folder. Paste the following into it:
node_modules
.git
.github
tests
coverage
*.md
.eslintrc*
eslint.config.js

Why exclude these files?

The container installs its own node_modules from the lockfile, so copying your local copy would waste space and cause conflicts. Test files, Git history, and linting config are development tools that a production container never uses.

  • Save .dockerignore.
  • Confirm both new files exist by running:
ls -a Dockerfile .dockerignore

You should see both Dockerfile and .dockerignore in the output.

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on to the build.

ⓧ I'd like to double check the full code

Here is the complete Dockerfile:

FROM node:22-alpine

WORKDIR /app

COPY package*.json ./

RUN npm ci --only=production

COPY src/ ./src/

EXPOSE 3000

CMD ["node", "src/index.js"]

And here is the complete .dockerignore:

node_modules
.git
.github
tests
coverage
*.md
.eslintrc*
eslint.config.js

Now build the Docker image from your Dockerfile.

  • Build the image and tag it ci-cd-api by running this command:
docker build -t ci-cd-api .

The build takes a moment as Docker downloads the Alpine base image and installs your dependencies. You should see the word FINISHED in the output when the build completes.

Build failing?

  • Make sure Docker Desktop is running. You can check by looking for the Docker whale icon in your menu bar (macOS) or system tray (Windows).
  • Confirm you're running the command from the ci-cd-api folder where your Dockerfile lives. The . at the end tells Docker to use the current directory as the build context.
  • Check that your Dockerfile has no typos. A single wrong character in a command like RUN npm ci --only=production will cause the build to fail.

Help me debug my Docker build error.

  • Run the container and map port 3000 by running this command:
docker run -p 3000:3000 ci-cd-api

You should see Server running on port 3000 printed in the terminal. The container is now running and occupying this terminal window.

What does -p 3000:3000 mean?

The -p flag maps a port on your machine (left side) to a port inside the container (right side). Since the Express app listens on port 3000 inside the container, you're making it accessible at localhost:3000 on your machine.

Test the running container

The container is running in the foreground, so you need a second terminal to send it a request.

  • Open a new terminal in VS Code by pressing Cmd+Shift+` (macOS) or Ctrl+Shift+` (Windows).
  • Test the health endpoint by running this command:
curl http://localhost:3000/health

You should see a JSON response like this:

{"status":"healthy","timestamp":"2026-06-18T12:00:00.000Z"}

What does this confirm?

The JSON response proves your API is running inside the Docker container and responding to HTTP requests. The timestamp will be different from the example, but the structure should match.

  • Switch back to the first terminal (the one showing Server running on port 3000).
  • Press Ctrl+C to stop the container.

The terminal returns to your normal prompt. The container is now stopped.

Curl returning 'connection refused'?

  • Make sure the container is still running in the other terminal. If you accidentally closed it, run docker run -p 3000:3000 ci-cd-api again.
  • Check that nothing else is using port 3000. If you left the API running from Step 1, stop it first.
  • Confirm you typed the URL exactly as http://localhost:3000/health with no trailing slash or typos.

Why is my containerized API not responding to curl?

Your API is now containerized and proven to work. Next up, you'll automate testing this container with a CI pipeline on GitHub Actions.

Create the CI Pipeline

Your API is tested locally and wrapped in a Docker container. But right now, running those tests depends on you remembering to do it.

In this step, you'll automate your linter and tests using GitHub Actions. Every time you push code, GitHub will spin up a fresh virtual machine, install your dependencies, and run your checks automatically. You'll also use matrix testing to verify your code works across two Node.js versions simultaneously.

In this step, get ready to:
  • Create a GitHub Actions workflow file that triggers on push and pull request.
  • Define matrix testing across Node.js 22 and 24.
  • Push your code and watch two parallel CI jobs pass.
Create the workflow directory and define the pipeline

GitHub Actions looks for workflow files in a specific directory inside your repository. You need to create this directory structure first.

  • Create the .github/workflows/ directory by running this command:
mkdir -p .github/workflows
  • Confirm the directory was created by running:
ls .github

You should see workflows in the output.

Now you'll create the workflow file that defines your CI pipeline. This file tells GitHub what to do when code is pushed.

  • Create a new file called ci.yml inside .github/workflows/ and paste the following code:
name: CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: ['22.x', '24.x']

    steps:

What does this configuration do?

  • name: CI/CD Pipeline gives the workflow a human-readable name that appears in the GitHub Actions tab.
  • on: push: branches: [main] triggers the workflow whenever someone pushes code to the main branch.
  • on: pull_request: branches: [main] also triggers it when a pull request targets main.
  • runs-on: ubuntu-latest runs the job on a fresh Ubuntu virtual machine provided by GitHub.
  • strategy: matrix: node-version: ['22.x', '24.x'] creates two parallel jobs, one for each Node.js version. This proves your code works on both current LTS versions.

Why test across two Node.js versions?

Node.js 22 is Maintenance LTS and Node.js 24 is Active LTS. Production teams test against multiple versions to catch compatibility issues before they reach users. Matrix testing runs both checks simultaneously, so it costs you no extra time.

Add the job steps

The steps: key is where you define the actual commands your CI job runs. Each step is one action in the pipeline.

  • In ci.yml, add the following steps directly below the steps: line (indented 6 spaces from the left):
      - name: Checkout code
        uses: actions/checkout@v6

      - name: Setup Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npm run lint

      - name: Run tests
        run: npm test

What does each step do?

  • actions/checkout@v6 clones your repository code into the virtual machine so the other steps can access it.
  • actions/setup-node@v4 installs the Node.js version from the matrix and enables npm caching to speed up future runs.
  • npm ci installs dependencies from the lockfile exactly. Unlike npm install, it never updates the lockfile, making builds reproducible.
  • npm run lint and npm test run the same linter and tests you ran locally. If either fails, the whole job fails.
  • Save .github/workflows/ci.yml.

✔️ Awesome, I've got everything!

Great. Double check you've saved the file before moving on.

ⓧ I'd like to double check the full code

name: CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: ['22.x', '24.x']

    steps:
      - name: Checkout code
        uses: actions/checkout@v6

      - name: Setup Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npm run lint

      - name: Run tests
        run: npm test

YAML not looking right?

  • YAML is extremely sensitive to indentation. Each level uses exactly 2 spaces. Make sure you are using spaces, not tabs.
  • The steps: key should be at the same indent level as strategy:. Each step item (- name:) is indented 6 spaces from the left margin.
  • Check that string values with special characters (like ${{ matrix.node-version }}) are not wrapped in extra quotes.

Why is my ci.yml indentation wrong and how do I fix it?

Push to GitHub and watch the pipeline run

Your workflow file is ready, but GitHub Actions only runs when the code lives on GitHub. You need to create a repository and push your project.

  • Open GitHub in your browser and navigate to github.com/new.
  • Type ci-cd-api in the **Repository name** field.
  • Select **Public** for visibility.
  • Leave all other options unchecked (no README, no .gitignore, no license).
  • Click **Create repository**.

Why no README or .gitignore?

You already have code locally. If GitHub creates files in the repository, your first push will hit a merge conflict. Starting with an empty repository keeps things clean.

GitHub shows a Quick Setup page with the repository URL. You'll use that URL in the next commands.

  • Initialize a Git repository and push your code by running these commands:
git init
git add .
git commit -m "Initial commit: API with CI pipeline"
git branch -M main
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/ci-cd-api.git"]]
git push -u origin main

What do these commands do?

  • git init turns your project folder into a Git repository.
  • git add . stages all your files for the first commit.
  • git branch -M main renames the default branch to main (matching what your workflow triggers on).
  • git remote add origin connects your local repo to the GitHub repository you just created.
  • git push -u origin main uploads your code to GitHub, which triggers the workflow.
  • Go back to your repository on GitHub.
  • Click the **Actions** tab at the top of the repository page.

You should see your workflow running. The matrix strategy creates two parallel jobs: one for Node.js 22.x and one for Node.js 24.x. Wait for both to finish with green checkmarks.

Actions tab not showing a workflow run?

  • Make sure your push went through. Run git log to confirm your commit exists, then check git remote -v to verify the remote URL matches your repository.
  • The workflow file must be at exactly .github/workflows/ci.yml. If you put it in the wrong directory, GitHub won't detect it.
  • If the jobs are failing, click on the failed job to see the log output. The error message tells you exactly which step broke.

My GitHub Actions workflow is not running or is failing. How do I debug it?

Your CI pipeline is live. Every push and pull request now gets automatically linted and tested across two Node.js versions. Next up, you'll extend this pipeline to build a Docker image and push it to a container registry.

Add the Docker Build and Push Jobs

Your CI pipeline proves the code works. Now it is time to ship it.

In this step you will add a second job that builds a Docker image from your API and pushes it to GitHub Container Registry (ghcr.io). The job only runs after lint-and-test passes, so bad code never makes it into a container.

In this step, get ready to:
  • Define a build-and-push job that depends on the lint-and-test job.
  • Add Docker login, metadata, and build steps to the workflow.
  • Push the updated workflow and verify the full pipeline on GitHub.
Add the build-and-push job definition

The new job needs to know three things: when to run, what it depends on, and what permissions it needs.

  • In VS Code, open .github/workflows/ci.yml.
  • Find the last line of the lint-and-test job (run: npm test).
  • Add the following code below it, leaving one blank line between the two jobs:
  build-and-push:
    runs-on: ubuntu-latest
    needs: lint-and-test
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    permissions:
      contents: read
      packages: write

What does this code do?

  • needs: lint-and-test tells GitHub Actions to wait until the lint-and-test job finishes successfully before starting this job.
  • The if conditional ensures the Docker build only runs on direct pushes to main. Pull requests skip it because you do not want untrusted code pushed to your registry.
  • permissions grants the minimum access the job needs: read the repo contents and write packages to the registry.
  • Save ci.yml.
  • You will not see a change yet. The next substep adds the steps this job runs.
Add the Docker login and push steps

The build-and-push job needs to authenticate with the registry, generate image tags, and then build and push the container.

  • In ci.yml, add the following directly below the packages: write line (with one blank line separating them):
    steps:
      - name: Checkout code
        uses: actions/checkout@v6

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata for Docker
        id: meta
        uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
        with:
          images: ghcr.io/${{ github.repository }}

      - name: Build and push Docker image
        uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

What does this code do?

  • docker/login-action authenticates with ghcr.io using the built-in GITHUB_TOKEN secret. No extra credentials to manage.
  • docker/metadata-action generates image tags and labels automatically from the Git ref (branch name, SHA). You do not have to hardcode version strings.
  • docker/build-push-action builds the image from your Dockerfile and pushes it to the registry in one step.
  • All three actions are pinned to commit SHAs instead of version tags. This prevents a compromised tag from injecting malicious code into your pipeline.

Why ghcr.io instead of Docker Hub?

GitHub Container Registry is free for public repos, has no pull rate limits, and authenticates with the same GITHUB_TOKEN your workflow already has. No separate Docker Hub account or API key required.

  • Save ci.yml.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before pushing.

ⓧ I'd like to double check the full code

name: CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: ['22.x', '24.x']

    steps:
      - name: Checkout code
        uses: actions/checkout@v6

      - name: Setup Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npm run lint

      - name: Run tests
        run: npm test

  build-and-push:
    runs-on: ubuntu-latest
    needs: lint-and-test
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout code
        uses: actions/checkout@v6

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata for Docker
        id: meta
        uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
        with:
          images: ghcr.io/${{ github.repository }}

      - name: Build and push Docker image
        uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
Push and verify the full pipeline
  • Commit and push your changes by running these commands:
git add .github/workflows/ci.yml
git commit -m "Add build-and-push job to CI pipeline"
git push origin main
  • Open your repository on GitHub.
  • Click the Actions tab.
  • Click the latest workflow run.

You should see the full pipeline visualised: the lint-and-test job spawns two parallel matrix runs (Node 22 and Node 24). After both pass, the build-and-push job runs.

  • Wait for all jobs to show green checkmarks.
  • Return to your repository's main page on GitHub.
  • In the right sidebar, click Packages.
  • Click the name of the package that appears. You should see your published Docker image with tag and metadata.

Don't see the build-and-push job?

  • Make sure you pushed directly to main. The if conditional skips the build on pull requests.
  • Check the indentation in your YAML. The build-and-push key must be at the same indent level as lint-and-test (two spaces under jobs:).
  • If you see a permissions error, confirm that permissions: is nested under build-and-push, not at the workflow root.

Why isn't my build-and-push job running after lint-and-test passes?

Your pipeline now automatically tests and ships a container on every push to main. Next up, you will intentionally break it to experience the feedback loop that keeps bad code out of production.

Break It and Watch It Fail

Your pipeline builds, tests, and ships a Docker image automatically. But how do you know it actually protects you from shipping broken code?

In this step, you will intentionally break a test to see the pipeline reject bad code. Then you will fix it, watch it pass, and merge it to trigger the full pipeline. This is the CI/CD feedback loop that engineering teams rely on every day.

In this step, get ready to:
  • Create a branch and intentionally break a test.
  • Open a Pull Request and watch CI block the merge.
  • Fix the test, merge, and trigger the full pipeline.
Create a branch and break a test

Git branches let you make changes in isolation without affecting your main branch. You will create a branch, break a test on purpose, and push it to GitHub as a Pull Request.

  • Create a new branch called feature/break-things by running this command:
git checkout -b feature/break-things

What does this command do?

The git checkout -b command creates a new branch and switches to it in one step. Any changes you make now are isolated from main.

Now you will break a test on purpose. The goal is to change an expected value so the assertion fails when the pipeline runs.

  • In tests/app.test.js, find this line inside the GET /api/status test:
expect(res.body.service).toBe("ci-cd-api");
  • Change "ci-cd-api" to "wrong-name", so the line looks like this:
expect(res.body.service).toBe("wrong-name");
  • Save tests/app.test.js.
  • Confirm the test fails locally by running:
npm test

You should see a failing test with a message about the expected value not matching the received value.

Test still passing?

Make sure you saved the file after editing. Double check you changed the value inside the GET /api/status test block, not the GET /health block.

Help me debug this

  • Stage, commit, and push the broken test to GitHub by running these commands:
git add .
git commit -m "break test on purpose"
git push -u origin feature/break-things

What does this command do?

The -u flag sets the upstream tracking branch so future pushes on this branch only need git push. The branch and its broken test are now on GitHub.

  • Open your repository on GitHub in your browser.
  • Click Compare & pull request in the banner that appears for your recently pushed branch.
  • Leave the title and description as-is and click Create pull request.

Don't see the banner?

If you do not see the banner, click the Pull requests tab, then click New pull request. Select feature/break-things as the compare branch and main as the base branch.

Help me create the PR manually

Watch CI fail on the Pull Request

Your workflow is configured to run on pull requests targeting main. GitHub will now run the pipeline against your broken code.

  • Scroll down on the Pull Request page to the checks section.
  • Wait 1-2 minutes for the pipeline to complete.
  • Look for a red X next to the lint-and-test job. Both matrix jobs (Node.js 22 and 24) will fail because the broken assertion exists in both runs.

Notice that the build-and-push job never runs. It depends on lint-and-test succeeding, so the chain stops.

Why did build-and-push not run?

The needs: lint-and-test line in the workflow means the Docker job waits for lint-and-test to pass. If the dependency fails, downstream jobs are skipped. This prevents broken code from ever being packaged and shipped.

Additionally, the if: condition restricts the build job to pushes to main only. Even if tests had passed, the Docker build would not trigger on a PR.

  • Scroll to the merge section at the bottom of the PR.

You should see a message saying "Some checks were not successful" with the merge button disabled or showing a warning.

What should I see?

GitHub displays a red status area at the bottom of the PR indicating checks failed. This is the CI safety net working exactly as intended. Broken code cannot be merged into main.

Fix the test, merge, and complete the loop

You have proven the pipeline catches broken code. Now fix the test, push again, and complete the full CI/CD feedback loop.

  • Return to tests/app.test.js in your editor.
  • Find this line:
expect(res.body.service).toBe("wrong-name");
  • Change "wrong-name" back to "ci-cd-api", so the line looks like this:
expect(res.body.service).toBe("ci-cd-api");
  • Save tests/app.test.js.
  • Confirm the test passes locally by running:
npm test

You should see all tests passing again.

  • Commit and push the fix by running these commands:
git add .
git commit -m "fix broken test"
git push
  • Return to your Pull Request on GitHub.
  • Wait 1-2 minutes for the pipeline to re-run.

You should now see green checkmarks next to both matrix jobs.

  • Click Merge pull request.
  • Click Confirm merge.

What happens after merging?

Merging to main triggers a push event. The workflow fires again, and this time the build-and-push job runs because all conditions are met: tests pass AND the event is a push to main.

A fresh Docker image is published to GitHub Container Registry.

  • Click the Actions tab in your repository.
  • Wait for all jobs to complete with green checkmarks.

You should see a new workflow run triggered by the merge commit. It shows lint-and-test (two matrix jobs) followed by build-and-push.

Build-and-push still not running?

Make sure you merged the PR (not just pushed the fix). The Docker job only triggers on a push to main, which happens when the merge commit lands.

Also confirm that the workflow file has the if: github.event_name == 'push' && github.ref == 'refs/heads/main' condition on the build-and-push job.

Help me figure out why the Docker build was skipped

  • Switch your local terminal back to main and pull the merged changes by running:
git checkout main
git pull

You now have a fully automated CI/CD feedback loop. Break code, watch CI reject it, fix it, watch CI approve it, and ship it.

✔️ Awesome, I've got everything!

Great. Double check you have saved all files and your local branch is back on main.

ⓧ I'd like to double check the full code

After fixing the test and merging, your tests/app.test.js should be back to its original state:

const request = require("supertest");
const app = require("../src/app");

describe("GET /health", () => {
  it("should return 200 and healthy status", async () => {
    const res = await request(app).get("/health");
    expect(res.status).toBe(200);
    expect(res.body.status).toBe("healthy");
    expect(res.body.timestamp).toBeDefined();
  });
});

describe("GET /api/status", () => {
  it("should return 200 and service info", async () => {
    const res = await request(app).get("/api/status");
    expect(res.status).toBe(200);
    expect(res.body.service).toBe("ci-cd-api");
    expect(res.body.version).toBe("1.0.0");
    expect(res.body.uptime).toBeDefined();
  });
});

Secret mission

Add a Test Coverage Gate

Real FAANG pipelines do not just check that tests pass. They enforce that test coverage stays above a threshold. If someone adds code without tests, the pipeline rejects it. Can you add a coverage gate to your pipeline?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on GitHub's free tier for public repositories, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • GitHub repository with CI/CD workflow and branch protection rules.
  • Docker image on GitHub Container Registry (ghcr.io).
  • Local ci-cd-api project folder on your Desktop.

Keep everything running

No action needed. Choose this if you plan to keep building on this pipeline or want to reference it later.

  • Your repository stays on GitHub at no cost for public repos.
  • GitHub Actions only runs when you push code, so there is no idle cost.
  • Your Docker image remains available on GitHub Container Registry (free for public repos).

Pause - I'll come back to this later

No action needed for cloud resources. GitHub Actions only triggers on pushes and PRs, so the pipeline sits dormant until you push again.

  • Stop any locally running containers by pressing Ctrl+C in the terminal where Docker is running.
  • Your repository, workflow history, and Docker images all remain intact at no cost.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your repository, workflow history, and published Docker images.

  • Go to your repository on GitHub.
  • Click Settings in the top navigation bar.
  • Scroll down to the Danger Zone section.
  • Click Delete this repository.
  • Click I want to delete this repository, read the warnings, then click I have read and understand these effects.
  • Type your repository name to confirm, then click Delete this repository.

Deleting the repository also removes all associated packages (your Docker image on ghcr.io) and workflow run history.

Delete the local project folder by running these commands in your terminal:

cd ~/Desktop
rm -rf ci-cd-api

Windows users

If you are on Windows, run this in PowerShell instead:

cd ~/Desktop
Remove-Item -Recurse -Force ci-cd-api

That's a Wrap!

That's a Wrap!

Nice work. You just built a production-grade CI/CD pipeline that automatically tests, builds, and ships a containerized API on every push.

You've learned how to:

  • Build a Node.js REST API with Express, automated tests with Jest, and linting with ESLint, then containerize it with Docker.
  • Create a multi-job GitHub Actions CI/CD pipeline with matrix testing across Node.js 22 and 24 to prove cross-version compatibility.
  • Automate Docker image publishing to GitHub Container Registry and experience the full CI/CD feedback loop: break code, watch it fail, fix it, and ship it.
  • Secret Mission: Add a coverage gate that enforces minimum 80% test coverage, preventing untested code from shipping.

Ready to quiz yourself?