Forem

Mohin Sheikh
Mohin Sheikh

Posted on

Building a Complete CI/CD Pipeline for Node.js TypeScript Project, A Step-by-Step Guide

Introduction

When I first started learning about CI/CD pipelines, I was overwhelmed by all the moving parts. GitHub Actions, Docker, testing, linting it seemed like a lot to grasp. After spending weeks figuring it all out, I decided to document my journey so others don't have to go through the same confusion.

In this guide, I'll walk you through building a production-ready Node.js TypeScript application with a complete CI/CD pipeline. We'll start from absolutely nothing and end with a fully automated system that tests, builds, and deploys your code automatically.

By the end of this tutorial, you'll have

  • A Node.js TypeScript project with proper configuration
  • Automated testing with Jest
  • Code quality tools (ESLint and Prettier)
  • Git hooks to catch issues before commits
  • A GitHub Actions pipeline that runs on every push
  • Docker containerization
  • Automatic Docker image publishing

Prerequisites

Before we start, make sure you have

  • Node.js installed (version 18 or higher)
  • Git installed
  • A GitHub account
  • Basic knowledge of JavaScript/TypeScript
  • A code editor (VS Code recommended)

Step 1 - Setting Up Your Project

Let's start by creating a new Node.js project and adding TypeScript.

Open your terminal and run these commands:

mkdir node-ts-cicd-demo
cd node-ts-cicd-demo
npm init -y
Enter fullscreen mode Exit fullscreen mode

This creates a basic package.json file. Now, let's install TypeScript and the necessary types:

npm install --save-dev typescript @types/node ts-node nodemon
Enter fullscreen mode Exit fullscreen mode

Create a tsconfig.json file in the root directory:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "moduleResolution": "node"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}
Enter fullscreen mode Exit fullscreen mode

This configuration tells TypeScript how to compile our code. We're targeting ES2022 and using CommonJS modules, which works well with Node.js.

Step 2 - Installing Express and Building a Simple API

Let's install Express to create a simple REST API

npm install express
npm install --save-dev @types/express
Enter fullscreen mode Exit fullscreen mode

Now create a src folder and add our application files. Create src/app.ts

import express, { Application, Request, Response, NextFunction } from 'express';

const app: Application = express();

app.use(express.json());

app.get('/health', (_req: Request, res: Response) => {
  res.json({ 
    status: 'OK', 
    timestamp: new Date().toISOString(),
    environment: process.env.NODE_ENV || 'development'
  });
});

app.get('/', (_req: Request, res: Response) => {
  res.json({ message: 'Welcome to Node.js TypeScript CI/CD Demo API' });
});

app.get('/api/users', (req: Request, res: Response) => {
  const { limit } = req.query;
  let users = [
    { id: 1, name: 'John Doe', email: 'john@example.com' },
    { id: 2, name: 'Jane Smith', email: 'jane@example.com' },
  ];

  if (limit && !isNaN(Number(limit))) {
    users = users.slice(0, Number(limit));
  }

  res.json({ users });
});

app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Something went wrong!' });
});

export default app;
Enter fullscreen mode Exit fullscreen mode

Create src/server.ts to start the server

import app from './app';

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

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

Add these scripts to your package.json:

{
  "scripts": {
    "start": "node dist/server.js",
    "dev": "nodemon --exec ts-node src/server.ts",
    "build": "tsc"
  }
}
Enter fullscreen mode Exit fullscreen mode

Test your setup by running npm run dev and visiting http://localhost:3000.

Step 3 - Adding Testing with Jest

Testing is crucial for maintaining code quality. Let's set up Jest

npm install --save-dev jest @types/jest ts-jest supertest @types/supertest
Enter fullscreen mode Exit fullscreen mode

Create jest.config.js

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src'],
  testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.d.ts',
    '!src/**/__tests__/**',
    '!src/server.ts',
  ],
  coverageThreshold: {
    global: {
      functions: 70,
      lines: 70,
      statements: 70
    }
  },
  coverageReporters: ['text', 'lcov', 'html'],
};
Enter fullscreen mode Exit fullscreen mode

Now create a test file at src/__tests__/app.test.ts

import request from 'supertest';
import app from '../app';

describe('App Tests', () => {
  describe('GET /health', () => {
    it('should return health status', async () => {
      const response = await request(app)
        .get('/health')
        .expect('Content-Type', /json/)
        .expect(200);

      expect(response.body).toHaveProperty('status', 'OK');
      expect(response.body).toHaveProperty('timestamp');
    });
  });

  describe('GET /', () => {
    it('should return welcome message', async () => {
      const response = await request(app)
        .get('/')
        .expect('Content-Type', /json/)
        .expect(200);

      expect(response.body).toHaveProperty(
        'message',
        'Welcome to Node.js TypeScript CI/CD Demo API'
      );
    });
  });

  describe('GET /api/users', () => {
    it('should return list of users', async () => {
      const response = await request(app)
        .get('/api/users')
        .expect('Content-Type', /json/)
        .expect(200);

      expect(response.body).toHaveProperty('users');
      expect(response.body.users).toHaveLength(2);
      expect(response.body.users[0]).toHaveProperty('name', 'John Doe');
    });
  });

  describe('Error Handling', () => {
    it('should handle 404 routes', async () => {
      await request(app)
        .get('/non-existent-route')
        .expect(404);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Add test scripts to package.json

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}
Enter fullscreen mode Exit fullscreen mode

Run npm test to verify everything works.

Step 4 - Code Quality with ESLint and Prettier

Consistent code formatting makes your codebase easier to maintain. Let's set up ESLint and Prettier:

npm install --save-dev eslint prettier @typescript-eslint/parser @typescript-eslint/eslint-plugin
Enter fullscreen mode Exit fullscreen mode

Create .eslintrc.json

{
  "parser": "@typescript-eslint/parser",
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended"
  ],
  "parserOptions": {
    "ecmaVersion": 2020,
    "sourceType": "module"
  },
  "env": {
    "node": true,
    "jest": true
  },
  "rules": {
    "no-console": "off",
    "@typescript-eslint/no-unused-vars": ["error", { 
      "argsIgnorePattern": "^_",
      "varsIgnorePattern": "^_" 
    }],
    "@typescript-eslint/explicit-function-return-type": "off",
    "@typescript-eslint/no-explicit-any": "warn"
  }
}
Enter fullscreen mode Exit fullscreen mode

Create .prettierrc

{
  "semi": true,
  "trailingComma": "es5",
  "singleQuote": true,
  "printWidth": 100,
  "tabWidth": 2
}
Enter fullscreen mode Exit fullscreen mode

Create .prettierignore

node_modules
dist
coverage
*.log
.env
Enter fullscreen mode Exit fullscreen mode

Add formatting scripts to package.json

{
  "scripts": {
    "lint": "eslint \"src/**/*.{js,ts}\"",
    "lint:fix": "eslint \"src/**/*.{js,ts}\" --fix",
    "format": "prettier --write \"src/**/*.{js,ts,json}\"",
    "format:check": "prettier --check \"src/**/*.{js,ts,json}\""
  }
}
Enter fullscreen mode Exit fullscreen mode

Run npm run lint and npm run format to see them in action.

Step 5 - Adding Git Hooks with Husky

We want to catch issues before they get committed. Let's add pre-commit hooks

npm install --save-dev husky lint-staged
npx husky init
Enter fullscreen mode Exit fullscreen mode

This creates a .husky folder with a pre-commit hook. Update the pre-commit file at .husky/pre-commit

npx lint-staged
Enter fullscreen mode Exit fullscreen mode

Now add lint-staged configuration to package.json

{
  "lint-staged": {
    "src/**/*.{js,ts}": [
      "eslint --fix",
      "prettier --write"
    ],
    "src/**/*.json": [
      "prettier --write"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Now every time you commit, it will automatically fix formatting and linting issues.

Step 6 - Creating Your GitHub Repository

Create a new repository on GitHub. Don't initialize it with a README or .gitignore. Then push your code:

git init
git add .
git commit -m "Initial commit"
git remote add origin https://github.com/yourusername/node-ts-cicd-demo.git
git branch -M main
git push -u origin main
Enter fullscreen mode Exit fullscreen mode

Step 7 - Setting Up GitHub Actions

Create the workflow file at .github/workflows/ci-cd.yml

name: CI/CD Pipeline

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

env:
  NODE_VERSION: '18'

jobs:
  build-and-test:
    name: Build and Test
    runs-on: ubuntu-latest

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

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

    - name: Install dependencies
      run: npm ci

    - name: Check formatting
      run: npm run format:check

    - name: Lint code
      run: npm run lint

    - name: Build project
      run: npm run build

    - name: Run tests with coverage
      run: npm run test:coverage

  security-scan:
    name: Security Scan
    runs-on: ubuntu-latest
    needs: build-and-test

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

    - name: Install dependencies
      run: npm ci

    - name: Run npm audit
      run: npm audit --audit-level=moderate || true
      continue-on-error: true
Enter fullscreen mode Exit fullscreen mode

This workflow runs on every push and pull request. It checks formatting, runs linting, builds the project, and runs tests.

Step 8 - Docker Containerization

Create a Dockerfile

FROM node:18-alpine AS builder

WORKDIR /app

COPY package*.json ./
COPY tsconfig.json ./

RUN npm ci

COPY src ./src

RUN npm run build

FROM node:18-alpine

WORKDIR /app

COPY package*.json ./

RUN npm ci --omit=dev --ignore-scripts

COPY --from=builder /app/dist ./dist

RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001 -G nodejs && \
    chown -R nodejs:nodejs /app

USER nodejs

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => {r.statusCode === 200 ? process.exit(0) : process.exit(1)})"

CMD ["node", "dist/server.js"]
Enter fullscreen mode Exit fullscreen mode

Create .dockerignore

node_modules
npm-debug.log
.git
.gitignore
README.md
.env
coverage
.vscode
*.log
Enter fullscreen mode Exit fullscreen mode

Test your Docker setup locally:

docker build -t node-ts-cicd-demo .
docker run -p 3000:3000 node-ts-cicd-demo
Enter fullscreen mode Exit fullscreen mode

Step 9 - Adding Docker Build to CI/CD

To automatically build and push Docker images, we need to add Docker Hub credentials to GitHub.

First, create a Docker Hub account at hub.docker.com. Then generate a personal access token

  1. Go to Docker Hub Settings
  2. Click "Security" → "New Access Token"
  3. Name it "cicd" and give it Read & Write permissions
  4. Copy the token

Now add secrets to your GitHub repository:

  1. Go to your repository Settings → Secrets and variables → Actions
  2. Add DOCKER_USERNAME with your Docker Hub username
  3. Add DOCKER_PASSWORD with your personal access token

Now update your workflow to include Docker build

  docker-build:
    name: Build and Push Docker Image
    runs-on: ubuntu-latest
    needs: build-and-test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

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

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

    - name: Log in to Docker Hub
      uses: docker/login-action@v3
      with:
        username: ${{ secrets.DOCKER_USERNAME }}
        password: ${{ secrets.DOCKER_PASSWORD }}

    - name: Build and push Docker image
      uses: docker/build-push-action@v5
      with:
        context: .
        push: true
        tags: |
          ${{ secrets.DOCKER_USERNAME }}/node-ts-cicd-demo:latest
          ${{ secrets.DOCKER_USERNAME }}/node-ts-cicd-demo:${{ github.sha }}
        cache-from: type=gha
        cache-to: type=gha,mode=max
Enter fullscreen mode Exit fullscreen mode

Step 10 - Handling Common Issues

Throughout this process, I encountered several issues. Here's how to fix them:

ESLint Configuration Error

If you see "ESLint couldn't find a configuration file", make sure your .eslintrc.json is in the root directory and properly formatted.

TypeScript Version Warning

If you see warnings about TypeScript version, downgrade to a compatible version:

npm install --save-dev typescript@5.5.4
Enter fullscreen mode Exit fullscreen mode

Jest Configuration Error

If Jest fails with "export default" error, change your jest.config.js to use CommonJS format:

module.exports = {
  // your config
};
Enter fullscreen mode Exit fullscreen mode

Docker Build Failing with Husky

If Docker fails with "husky not found", add --ignore-scripts to your Dockerfile:

RUN npm ci --omit=dev --ignore-scripts
Enter fullscreen mode Exit fullscreen mode

Docker Hub Authentication Failed

Make sure you're using a personal access token, not your password, and that it has Read & Write permissions.

Step 11 - Testing Your Complete Pipeline

After pushing your code to GitHub, your pipeline will automatically run. Here's what happens

  1. On every push, the build-and-test job runs
  2. It checks code formatting with Prettier
  3. It runs ESLint to catch code issues
  4. It builds the TypeScript code
  5. It runs all tests and checks coverage
  6. On the main branch, it builds a Docker image
  7. It pushes the image to Docker Hub with tags

You can see the entire process in the Actions tab of your GitHub repository.

Step 12 - Deploying Your Application

Now that your Docker image is on Docker Hub, you can deploy it anywhere. Here's a simple way to run it:

docker pull yourusername/node-ts-cicd-demo:latest
docker run -d -p 3000:3000 yourusername/node-ts-cicd-demo:latest
Enter fullscreen mode Exit fullscreen mode

Your application will be running on port 3000. Visit http://localhost:3000/health to see it working.

What We Built

Let's recap everything we accomplished

  • A TypeScript Node.js application with Express
  • Automated testing with Jest and Supertest
  • Code quality tools (ESLint and Prettier)
  • Git hooks to enforce quality before commits
  • A GitHub Actions workflow that runs on every push
  • Security scanning with npm audit
  • Docker containerization with a production-ready image
  • Automatic Docker image publishing to Docker Hub

Best Practices We Followed

Throughout this project, we incorporated several best practices:

Version Control - We used Git with meaningful commit messages and proper .gitignore files.

Code Quality - ESLint and Prettier ensure consistent code style and catch common issues.

Testing - We wrote tests for our API endpoints and set coverage thresholds.

Security - We ran security audits and used environment variables for secrets.

Containerization - We created a multi-stage Docker build for smaller, more secure images.

CI/CD - We automated everything so that every push triggers the entire pipeline.

Troubleshooting Guide

Here are solutions to common issues you might encounter

Docker Build Fails - Check your Dockerfile syntax and ensure all required files are present. Use docker build --no-cache to clear any cache issues.

Tests Fail in CI - Run tests locally first. If they pass locally but fail in CI, check for environment differences like line endings or file paths.

ESLint Errors - Run npm run lint:fix to automatically fix common issues. For remaining issues, check the rule documentation.

GitHub Actions Not Triggering - Ensure your workflow file is in the correct directory (.github/workflows/) and has the .yml extension.

Resources for Further Learning

To deepen your understanding, I recommend

  • TypeScript Handbook for advanced TypeScript features
  • Jest documentation for more testing patterns
  • GitHub Actions documentation for custom workflows
  • Docker best practices guide for optimizing images
  • ESLint rules documentation for customizing your setup

Project Repository

You can find the complete source code for this project on GitHub

Repository - nodejs-ci-cd-demo

Feel free to clone, fork, or reference it as you follow along with this tutorial.


Complete Project Folder Structure

After following all the steps, your project should have the following structure

nodejs-ci-cd-demo/
│
├── .github/
│   └── workflows/
│       └── ci-cd.yml                 # GitHub Actions CI/CD pipeline
│
├── src/
│   ├── __tests__/
│   │   ├── app.test.ts               # Unit tests for the application
│   │   ├── error-handling.test.ts    # Error handling tests
│   │   ├── logger.test.ts            # Logger utility tests
│   │   └── server.test.ts            # Server tests
│   ├── app.ts                        # Main Express application
│   ├── logger.ts                     # Logger utility
│   └── server.ts                     # Server entry point
│
├── .dockerignore                     # Files to exclude from Docker build
├── .eslintrc.json                    # ESLint configuration
├── .gitignore                        # Git ignore rules
├── .prettierignore                   # Prettier ignore rules
├── .prettierrc                       # Prettier configuration
├── Dockerfile                        # Docker multi-stage build configuration
├── jest.config.js                    # Jest testing configuration
├── package.json                      # Project dependencies and scripts
├── package-lock.json                 # Locked dependency versions
├── tsconfig.json                     # TypeScript compiler options
└── README.md                         # Project documentation
Enter fullscreen mode Exit fullscreen mode

Key Files Explained

.github/workflows/ci-cd.yml - Contains the complete CI/CD pipeline with jobs for building, testing, security scanning, and Docker image publishing.

src/app.ts - The main Express application with API endpoints including health checks, welcome message, and user listing with query parameter support.

Dockerfile - Multi-stage build that compiles TypeScript in one stage and creates a lightweight production image in another.

jest.config.js - Jest configuration with coverage thresholds and test patterns.

tsconfig.json - TypeScript configuration using CommonJS modules for Node.js compatibility.


What's Next?

Now that you have a fully automated CI/CD pipeline, here are some ways to extend this project

  • Add end-to-end testing with Cypress or Playwright
  • Implement database integration with PostgreSQL or MongoDB
  • Add authentication using JWT or OAuth
  • Set up monitoring with Prometheus and Grafana
  • Deploy to cloud platforms like AWS ECS, Google Cloud Run, or Azure Container Instances
  • Add performance testing with Artillery or K6
  • Implement feature flags for gradual rollouts

Final Thoughts

Building a CI/CD pipeline is one of the most valuable skills a developer can learn. It transforms how you ship code, giving you confidence that every change is tested, validated, and ready for production. The setup we've built today is the same pattern used by companies of all sizes, from startups to large enterprises.

I hope this guide helped you understand not just the "how" but also the "why" behind each tool and configuration. Remember, the best way to learn is to build, break, and fix things. Don't be afraid to experiment with your pipeline, add new features, and make it your own.

If you found this guide helpful, please share it with others who might benefit. Feel free to leave a comment with any questions or suggestions for improvement. Happy coding!

I hope this guide helps you on your journey to building robust, production-ready applications. If you have questions or run into issues, feel free to refer back to this guide or explore the documentation of the tools we used.


Author: Mohin Sheikh

Follow me on GitHub for more insights and projects!

Top comments (0)