Setting Up GitHub Actions for Node.js CI/CD Pipeline
Back to blog
github-actionscicddevopsnodejsautomation

Setting Up GitHub Actions for Node.js CI/CD Pipeline

Comprehensive guide to automating testing, building, and deployment of Node.js applications using GitHub Actions with Docker and production workflows.

February 15, 2025
5 min read

Setting Up GitHub Actions for Node.js CI/CD Pipeline

GitHub Actions provides free CI/CD automation directly in your repository. This guide walks through creating a production-ready pipeline that tests, builds, and deploys Node.js applications with Docker.

Why Automate With GitHub Actions?

Manual deployments are error-prone and slow. GitHub Actions runs on every code push, automatically running tests, building Docker images, and deploying to production. This ensures code quality and reduces deployment overhead.

Prerequisites

  • GitHub repository with Node.js application
  • Docker Hub or container registry account
  • VPS with Docker and SSH access
  • Node.js application with package.json and tests

Step 1: Create Workflow Directory

Create the GitHub Actions workflows directory:

mkdir -p .github/workflows

This directory stores all CI/CD workflow files.

Step 2: Create Test Workflow

Create .github/workflows/test.yml:

name: Run Tests

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

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [18.x, 20.x]

    services:
      postgres:
        image: postgres:15-alpine
        env:
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v4

      - name: Use 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 --if-present

      - name: Run type check
        run: npm run type-check --if-present

      - name: Run tests
        run: npm test
        env:
          DATABASE_URL: postgresql://postgres:testpass@localhost:5432/testdb
          NODE_ENV: test

      - name: Upload coverage reports
        uses: codecov/codecov-action@v3
        with:
          file: ./coverage/coverage-final.json
          flags: unittests
          name: codecov-umbrella

Step 3: Create Build and Push Workflow

Create .github/workflows/build-push.yml:

name: Build and Push Docker Image

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

env:
  REGISTRY: docker.io
  IMAGE_NAME: yourusername/myapp

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

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

      - name: Log in to Docker Hub
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha,prefix={{branch}}-

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
          cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max

Step 4: Create Deployment Workflow

Create .github/workflows/deploy.yml:

name: Deploy to VPS

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'

    steps:
      - uses: actions/checkout@v4

      - name: Deploy to VPS
        env:
          VPS_HOST: ${{ secrets.VPS_HOST }}
          VPS_USER: ${{ secrets.VPS_USER }}
          VPS_PRIVATE_KEY: ${{ secrets.VPS_PRIVATE_KEY }}
          VPS_PORT: ${{ secrets.VPS_PORT }}
          DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
          DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
        run: |
          mkdir -p ~/.ssh
          echo "$VPS_PRIVATE_KEY" > ~/.ssh/id_rsa
          chmod 600 ~/.ssh/id_rsa
          ssh-keyscan -p $VPS_PORT -H $VPS_HOST >> ~/.ssh/known_hosts

          ssh -p $VPS_PORT $VPS_USER@$VPS_HOST << 'EOF'
            cd /app
            docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN
            docker pull yourusername/myapp:main
            docker-compose down
            docker-compose up -d
            docker exec myapp npm run migrate
            EOF

      - name: Health check
        run: |
          for i in {1..30}; do
            curl -f https://api.example.com/health && exit 0 || sleep 1
          done
          exit 1

Step 5: Configure Secrets

In GitHub repository, go to Settings > Secrets and variables > Actions.

Add these secrets:

Secret Value
DOCKERHUB_USERNAME Your Docker Hub username
DOCKERHUB_TOKEN Docker Hub access token
VPS_HOST VPS IP address or hostname
VPS_USER SSH username
VPS_PRIVATE_KEY SSH private key (full content)
VPS_PORT SSH port (usually 22)

Generate SSH key pair (if you don't have one):

ssh-keygen -t ed25519 -C "github-actions" -f ~/.ssh/github_actions
cat ~/.ssh/github_actions
# Copy the output to VPS_PRIVATE_KEY secret

Add public key to VPS:

cat ~/.ssh/github_actions.pub >> ~/.ssh/authorized_keys

Step 6: Create Dockerfile

Create Dockerfile:

FROM node:20-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .

RUN npm run build

# Production stage
FROM node:20-alpine

WORKDIR /app

RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001

COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force

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

USER nodejs

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"

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

Step 7: Create docker-compose.yml

Create docker-compose.yml for production:

version: '3.8'

services:
  app:
    image: yourusername/myapp:main
    container_name: myapp
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - DATABASE_URL=${DATABASE_URL}
      - JWT_SECRET=${JWT_SECRET}
      - API_KEY=${API_KEY}
    networks:
      - app-network
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 40s

  nginx:
    image: nginx:alpine
    container_name: nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - /etc/letsencrypt:/etc/letsencrypt:ro
    depends_on:
      - app
    networks:
      - app-network

networks:
  app-network:
    driver: bridge

Step 8: Create Migration Script

Create scripts/migrate.js:

import { db } from '../src/database';

async function runMigrations() {
  try {
    console.log('Running migrations...');
    // Your migration logic here
    await db.migrate();
    console.log('Migrations completed successfully');
    process.exit(0);
  } catch (error) {
    console.error('Migration failed:', error);
    process.exit(1);
  }
}

runMigrations();

Add to package.json:

{
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "ts-node src/index.ts",
    "test": "jest",
    "lint": "eslint src",
    "type-check": "tsc --noEmit",
    "migrate": "ts-node scripts/migrate.js"
  }
}

Step 9: Monitor Workflow Runs

View workflow status in GitHub:

  1. Go to your repository
  2. Click Actions tab
  3. Select a workflow to view details
  4. Click individual runs to see logs

Step 10: Advanced Features

Matrix Testing

Test against multiple environments:

strategy:
  matrix:
    node-version: [18.x, 20.x]
    postgres-version: [14, 15]
    include:
      - node-version: 20.x
        postgres-version: 15
        coverage: true

Conditional Steps

Skip deployment on pull requests:

if: github.event_name == 'push' && github.ref == 'refs/heads/main'

Notifications

Slack notification on failure:

- name: Notify Slack
  if: failure()
  uses: slackapi/slack-github-action@v1
  with:
    webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}

Best Practices

  • Never commit secrets: Always use GitHub Secrets
  • Use caching: Cache node_modules and Docker layers
  • Run tests on PR: Test before merging to main
  • Semantic versioning: Tag releases with versions
  • Monitor workflows: Set up Slack/email notifications
  • Document deployments: Add README with deployment instructions
Workflow StageActionPurpose
TriggerPush to main/devInitiate pipeline
TestJest/VitestRun unit tests
BuildDocker buildCreate container image
PushDocker Hub/ECRStore image registry
DeployVPS SSHUpdate production

Troubleshooting

Workflow not triggering: Check branch names and push events in workflow yaml

Docker authentication fails: Verify DOCKERHUB_TOKEN is valid (not password)

SSH connection timeout: Ensure VPS firewall allows GitHub Actions IPs

Database connection errors: Check services section and environment variables

Useful Resources

Conclusion

You now have a fully automated CI/CD pipeline that tests, builds, and deploys your Node.js application. This eliminates manual steps and ensures consistent, reliable deployments to production.