DevOps

CI/CD Pipelines with GitHub Actions for Node.js

By Mohd Baquir Qureshi
Code Pipeline

Continuous Integration and Continuous Deployment (CI/CD) is no longer a luxury; it's a necessity. GitHub Actions has revolutionized this space by providing powerful, native automation directly within your repository. Let's look at a production-grade workflow for a Node.js project.

1. The Continuous Integration (CI) Workflow

The goal of CI is to prove that new code integrates cleanly with the main branch. We want to run linting, type-checking (if using TypeScript), and unit tests on every Pull Request.

name: Node.js CI

on:
  pull_request:
    branches: [ "main" ]

jobs:
  build-and-test:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v4
    
    - name: Use Node.js 20.x
      uses: actions/setup-node@v4
      with:
        node-version: '20.x'
        cache: 'npm' # CRITICAL: Caches ~/.npm to speed up npm ci

    - name: Install dependencies
      run: npm ci # Use ci instead of install for reproducible builds

    - name: Run linter
      run: npm run lint

    - name: Run tests
      run: npm test

Why npm ci?

Never use npm install in a CI pipeline. It can update your package-lock.json unexpectedly. npm ci reads strictly from the lockfile, ensuring deterministic, reproducible builds.

2. Database Services in CI

If your integration tests require a real database, you can spin up ephemeral Docker containers directly within the GitHub Action runner using services.

jobs:
  test-with-db:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: password
          POSTGRES_DB: test_db
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432
    # ... steps ...

3. The Continuous Deployment (CD) Workflow

Once code is merged to main, we want to automatically deploy it. A common pattern is building a Docker image and pushing it to a registry (like AWS ECR or GitHub Container Registry), then triggering a server to pull the new image.

name: Deploy to Production

on:
  push:
    branches: [ "main" ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    needs: build-and-test # Ensure tests pass first!

    steps:
    - uses: actions/checkout@v4
    
    - name: Login to DockerHub
      uses: docker/login-action@v3
      with:
        username: ${{ secrets.DOCKERHUB_USERNAME }}
        password: ${{ secrets.DOCKERHUB_TOKEN }}
        
    - name: Build and push
      uses: docker/build-push-action@v5
      with:
        push: true
        tags: myorg/myapp:latest,myorg/myapp:${{ github.sha }}
        
    - name: Deploy via SSH
      uses: appleboy/ssh-action@v1.0.3
      with:
        host: ${{ secrets.PROD_SERVER_IP }}
        username: deploy
        key: ${{ secrets.SSH_PRIVATE_KEY }}
        script: |
          docker pull myorg/myapp:latest
          docker-compose -f docker-compose.prod.yml up -d

Conclusion

GitHub Actions removes the need to maintain separate Jenkins servers. By defining your CI/CD pipelines as code within your repository, leveraging caching, and utilizing built-in marketplace actions, you can ensure that your deployments are fast, automated, and strictly validated.