`n

CI/CD Pipeline Setup - Complete Guide

Published: September 25, 2024 | Reading time: 25 minutes

CI/CD Pipeline Overview

Continuous Integration and Continuous Deployment automate software delivery:

CI/CD Benefits
# Key Benefits
- Automated testing
- Faster deployments
- Reduced human error
- Consistent environments
- Quick feedback loops
- Rollback capabilities
- Team collaboration

GitHub Actions Setup

Basic Workflow

Node.js CI/CD Pipeline
# .github/workflows/ci-cd.yml
name: CI/CD Pipeline

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

env:
  NODE_VERSION: '18'
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v3
    
    - name: Setup Node.js
      uses: actions/setup-node@v3
      with:
        node-version: ${{ env.NODE_VERSION }}
        cache: 'npm'
    
    - name: Install dependencies
      run: npm ci
    
    - name: Run linting
      run: npm run lint
    
    - name: Run tests
      run: npm test
    
    - name: Run security audit
      run: npm audit --audit-level moderate
    
    - name: Generate coverage report
      run: npm run test:coverage
    
    - name: Upload coverage to Codecov
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage/lcov.info

  build:
    needs: test
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v3
    
    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v2
    
    - name: Login to Container Registry
      uses: docker/login-action@v2
      with:
        registry: ${{ env.REGISTRY }}
        username: ${{ github.actor }}
        password: ${{ secrets.GITHUB_TOKEN }}
    
    - name: Extract metadata
      id: meta
      uses: docker/metadata-action@v4
      with:
        images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
        tags: |
          type=ref,event=branch
          type=ref,event=pr
          type=sha,prefix={{branch}}-
          type=raw,value=latest,enable={{is_default_branch}}
    
    - name: Build and push Docker image
      uses: docker/build-push-action@v4
      with:
        context: .
        push: true
        tags: ${{ steps.meta.outputs.tags }}
        labels: ${{ steps.meta.outputs.labels }}
        cache-from: type=gha
        cache-to: type=gha,mode=max

  deploy:
    needs: build
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    
    steps:
    - name: Deploy to production
      uses: appleboy/ssh-action@v0.1.5
      with:
        host: ${{ secrets.HOST }}
        username: ${{ secrets.USERNAME }}
        key: ${{ secrets.SSH_KEY }}
        script: |
          docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
          docker-compose down
          docker-compose up -d
          docker system prune -f

Advanced GitHub Actions

Multi-Environment Deployment
# .github/workflows/deploy.yml
name: Multi-Environment Deployment

on:
  push:
    branches: [ main, staging, develop ]
  workflow_dispatch:
    inputs:
      environment:
        description: 'Environment to deploy'
        required: true
        default: 'staging'
        type: choice
        options:
        - staging
        - production

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v3
    
    - name: Setup Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '18'
        cache: 'npm'
    
    - name: Install dependencies
      run: npm ci
    
    - name: Build application
      run: npm run build
      env:
        NODE_ENV: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
    
    - name: Deploy to staging
      if: github.ref == 'refs/heads/develop'
      run: |
        echo "Deploying to staging environment"
        # Add staging deployment commands
    
    - name: Deploy to production
      if: github.ref == 'refs/heads/main'
      run: |
        echo "Deploying to production environment"
        # Add production deployment commands
    
    - name: Notify deployment
      uses: 8398a7/action-slack@v3
      with:
        status: ${{ job.status }}
        channel: '#deployments'
        webhook_url: ${{ secrets.SLACK_WEBHOOK }}

Jenkins Setup

Jenkins Pipeline

Jenkinsfile
// Jenkinsfile
pipeline {
    agent any
    
    environment {
        NODE_VERSION = '18'
        DOCKER_REGISTRY = 'your-registry.com'
        IMAGE_NAME = 'myapp'
    }
    
    stages {
        stage('Checkout') {
            steps {
                checkout scm
            }
        }
        
        stage('Install Dependencies') {
            steps {
                sh 'npm ci'
            }
        }
        
        stage('Lint') {
            steps {
                sh 'npm run lint'
            }
        }
        
        stage('Test') {
            steps {
                sh 'npm test'
            }
            post {
                always {
                    publishTestResults testResultsPattern: 'test-results.xml'
                    publishCoverage adapters: [coberturaAdapter('coverage/cobertura-coverage.xml')]
                }
            }
        }
        
        stage('Security Scan') {
            steps {
                sh 'npm audit --audit-level moderate'
            }
        }
        
        stage('Build Docker Image') {
            steps {
                script {
                    def image = docker.build("${DOCKER_REGISTRY}/${IMAGE_NAME}:${env.BUILD_NUMBER}")
                    docker.withRegistry("https://${DOCKER_REGISTRY}", 'docker-registry-credentials') {
                        image.push()
                        image.push('latest')
                    }
                }
            }
        }
        
        stage('Deploy to Staging') {
            when {
                branch 'develop'
            }
            steps {
                sh '''
                    docker pull ${DOCKER_REGISTRY}/${IMAGE_NAME}:${BUILD_NUMBER}
                    docker-compose -f docker-compose.staging.yml up -d
                '''
            }
        }
        
        stage('Deploy to Production') {
            when {
                branch 'main'
            }
            steps {
                input message: 'Deploy to production?', ok: 'Deploy'
                sh '''
                    docker pull ${DOCKER_REGISTRY}/${IMAGE_NAME}:${BUILD_NUMBER}
                    docker-compose -f docker-compose.prod.yml up -d
                '''
            }
        }
    }
    
    post {
        always {
            cleanWs()
        }
        success {
            slackSend channel: '#deployments', message: "✅ Deployment successful: ${env.JOB_NAME} - ${env.BUILD_NUMBER}"
        }
        failure {
            slackSend channel: '#deployments', message: "❌ Deployment failed: ${env.JOB_NAME} - ${env.BUILD_NUMBER}"
        }
    }
}

GitLab CI/CD

GitLab Pipeline

.gitlab-ci.yml
# .gitlab-ci.yml
stages:
  - test
  - build
  - deploy

variables:
  NODE_VERSION: "18"
  DOCKER_DRIVER: overlay2
  DOCKER_TLS_CERTDIR: "/certs"

# Test stage
test:
  stage: test
  image: node:18-alpine
  before_script:
    - npm ci
  script:
    - npm run lint
    - npm test
    - npm run test:coverage
  coverage: '/Lines\s*:\s*(\d+\.\d+)%/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml
    paths:
      - coverage/
    expire_in: 1 week

# Security scan
security_scan:
  stage: test
  image: node:18-alpine
  script:
    - npm audit --audit-level moderate
  allow_failure: true

# Build Docker image
build:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker build -t $CI_REGISTRY_IMAGE:latest .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
    - docker push $CI_REGISTRY_IMAGE:latest
  only:
    - main
    - develop

# Deploy to staging
deploy_staging:
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add --no-cache openssh-client
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - ssh-keyscan $STAGING_HOST >> ~/.ssh/known_hosts
  script:
    - ssh $STAGING_USER@$STAGING_HOST "docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
    - ssh $STAGING_USER@$STAGING_HOST "docker-compose -f docker-compose.staging.yml up -d"
  environment:
    name: staging
    url: https://staging.example.com
  only:
    - develop

# Deploy to production
deploy_production:
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add --no-cache openssh-client
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - ssh-keyscan $PRODUCTION_HOST >> ~/.ssh/known_hosts
  script:
    - ssh $PRODUCTION_USER@$PRODUCTION_HOST "docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
    - ssh $PRODUCTION_USER@$PRODUCTION_HOST "docker-compose -f docker-compose.prod.yml up -d"
  environment:
    name: production
    url: https://example.com
  only:
    - main
  when: manual

Testing Strategies

Test Automation

Testing Pipeline
# Test configuration
# package.json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "test:e2e": "cypress run",
    "test:integration": "jest --config jest.integration.config.js",
    "lint": "eslint .",
    "lint:fix": "eslint . --fix",
    "type-check": "tsc --noEmit"
  },
  "jest": {
    "collectCoverageFrom": [
      "src/**/*.{js,ts}",
      "!src/**/*.d.ts",
      "!src/**/*.test.{js,ts}",
      "!src/**/*.spec.{js,ts}"
    ],
    "coverageThreshold": {
      "global": {
        "branches": 80,
        "functions": 80,
        "lines": 80,
        "statements": 80
      }
    }
  }
}

# Jest configuration
# jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['/src', '/tests'],
  testMatch: [
    '**/__tests__/**/*.+(ts|tsx|js)',
    '**/*.(test|spec).+(ts|tsx|js)'
  ],
  transform: {
    '^.+\\.(ts|tsx)$': 'ts-jest'
  },
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts'
  ],
  coverageDirectory: 'coverage',
  coverageReporters: ['text', 'lcov', 'html']
};

# Cypress E2E tests
# cypress.config.js
const { defineConfig } = require('cypress');

module.exports = defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    supportFile: 'cypress/support/e2e.js',
    specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
    video: true,
    screenshotOnRunFailure: true
  }
});

Deployment Strategies

Blue-Green Deployment

Blue-Green Script
#!/bin/bash
# blue-green-deploy.sh

set -e

APP_NAME="myapp"
BLUE_PORT="3000"
GREEN_PORT="3001"
NGINX_CONFIG="/etc/nginx/sites-available/myapp"

# Function to check if service is healthy
check_health() {
    local port=$1
    local max_attempts=30
    local attempt=1
    
    while [ $attempt -le $max_attempts ]; do
        if curl -f http://localhost:$port/health > /dev/null 2>&1; then
            echo "Service on port $port is healthy"
            return 0
        fi
        echo "Attempt $attempt: Service on port $port not ready yet..."
        sleep 2
        attempt=$((attempt + 1))
    done
    
    echo "Service on port $port failed health check"
    return 1
}

# Function to get current active color
get_active_color() {
    if grep -q "proxy_pass http://localhost:$BLUE_PORT" $NGINX_CONFIG; then
        echo "blue"
    else
        echo "green"
    fi
}

# Function to switch traffic
switch_traffic() {
    local target_port=$1
    local target_color=$2
    
    echo "Switching traffic to $target_color (port $target_port)"
    
    # Update nginx configuration
    sed -i "s/proxy_pass http:\/\/localhost:[0-9]*/proxy_pass http:\/\/localhost:$target_port/" $NGINX_CONFIG
    
    # Test nginx configuration
    nginx -t
    
    # Reload nginx
    systemctl reload nginx
    
    echo "Traffic switched to $target_color"
}

# Main deployment logic
deploy() {
    local current_color=$(get_active_color)
    local new_color
    
    if [ "$current_color" = "blue" ]; then
        new_color="green"
        new_port=$GREEN_PORT
        old_port=$BLUE_PORT
    else
        new_color="blue"
        new_port=$BLUE_PORT
        old_port=$GREEN_PORT
    fi
    
    echo "Current active: $current_color, Deploying to: $new_color"
    
    # Deploy new version
    echo "Deploying new version to $new_color..."
    docker run -d --name ${APP_NAME}-${new_color} -p $new_port:3000 ${APP_NAME}:latest
    
    # Wait for service to be healthy
    if check_health $new_port; then
        echo "New version is healthy, switching traffic..."
        switch_traffic $new_port $new_color
        
        # Wait a bit, then stop old version
        sleep 10
        echo "Stopping old version ($current_color)..."
        docker stop ${APP_NAME}-${current_color} || true
        docker rm ${APP_NAME}-${current_color} || true
        
        echo "Deployment completed successfully"
    else
        echo "New version failed health check, rolling back..."
        docker stop ${APP_NAME}-${new_color} || true
        docker rm ${APP_NAME}-${new_color} || true
        exit 1
    fi
}

# Run deployment
deploy

Monitoring and Alerting

Pipeline Monitoring

Monitoring Setup
# GitHub Actions with monitoring
- name: Deploy with monitoring
  run: |
    # Deploy application
    docker-compose up -d
    
    # Wait for deployment
    sleep 30
    
    # Health check
    if curl -f http://localhost:3000/health; then
      echo "Deployment successful"
      
      # Send success notification
      curl -X POST -H 'Content-type: application/json' \
        --data '{"text":"✅ Deployment successful: '${{ github.sha }}'"}' \
        ${{ secrets.SLACK_WEBHOOK }}
    else
      echo "Deployment failed"
      
      # Send failure notification
      curl -X POST -H 'Content-type: application/json' \
        --data '{"text":"❌ Deployment failed: '${{ github.sha }}'"}' \
        ${{ secrets.SLACK_WEBHOOK }}
      
      # Rollback
      docker-compose down
      docker-compose -f docker-compose.previous.yml up -d
      exit 1
    fi

# Prometheus monitoring
# prometheus.yml
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'node-app'
    static_configs:
      - targets: ['localhost:3000']
    metrics_path: '/metrics'
    scrape_interval: 5s

  - job_name: 'nginx'
    static_configs:
      - targets: ['localhost:9113']

# Grafana dashboard for CI/CD metrics
# dashboard.json
{
  "dashboard": {
    "title": "CI/CD Pipeline Metrics",
    "panels": [
      {
        "title": "Deployment Success Rate",
        "type": "stat",
        "targets": [
          {
            "expr": "rate(deployment_success_total[5m]) / rate(deployment_total[5m]) * 100"
          }
        ]
      },
      {
        "title": "Build Duration",
        "type": "graph",
        "targets": [
          {
            "expr": "histogram_quantile(0.95, rate(build_duration_seconds_bucket[5m]))"
          }
        ]
      }
    ]
  }
}

Security in CI/CD

Security Scanning

Security Pipeline
# Security scanning workflow
name: Security Scan

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

jobs:
  security-scan:
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v3
    
    - name: Run Trivy vulnerability scanner
      uses: aquasecurity/trivy-action@master
      with:
        scan-type: 'fs'
        scan-ref: '.'
        format: 'sarif'
        output: 'trivy-results.sarif'
    
    - name: Upload Trivy scan results
      uses: github/codeql-action/upload-sarif@v2
      with:
        sarif_file: 'trivy-results.sarif'
    
    - name: Run Snyk security scan
      uses: snyk/actions/node@master
      env:
        SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
      with:
        args: --severity-threshold=high
    
    - name: Run CodeQL Analysis
      uses: github/codeql-action/analyze@v2
      with:
        languages: javascript
    
    - name: Run OWASP ZAP Baseline Scan
      uses: zaproxy/action-baseline@v0.7.0
      with:
        target: 'http://localhost:3000'
        rules_file_name: '.zap/rules.tsv'
        cmd_options: '-a'

# Docker security scanning
# Dockerfile with security best practices
FROM node:18-alpine

# Install security updates
RUN apk update && apk upgrade

# Create non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001

# Set working directory
WORKDIR /app

# Copy package files
COPY package*.json ./

# Install dependencies with security audit
RUN npm ci --only=production && npm audit --audit-level moderate

# Copy application code
COPY --chown=nextjs:nodejs . .

# Switch to non-root user
USER nextjs

# Expose port
EXPOSE 3000

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD curl -f http://localhost:3000/health || exit 1

# Start application
CMD ["node", "index.js"]

Best Practices

CI/CD Checklist

Pipeline Design

  • Fast feedback loops
  • Parallel job execution
  • Fail fast on errors
  • Comprehensive testing
  • Security scanning
  • Environment parity
  • Rollback capabilities

Deployment Strategy

  • Blue-green deployments
  • Canary releases
  • Feature flags
  • Database migrations
  • Health checks
  • Monitoring alerts
  • Automated rollbacks

Summary

CI/CD pipeline setup involves several key components:

  • Platform Selection: GitHub Actions, Jenkins, GitLab CI
  • Testing Strategy: Unit, integration, E2E tests
  • Build Process: Docker images, artifacts
  • Deployment: Blue-green, canary, rolling updates
  • Security: Vulnerability scanning, secrets management
  • Monitoring: Health checks, alerts, metrics
  • Best Practices: Fast feedback, environment parity

Need More Help?

Struggling with CI/CD pipeline setup or need help automating your deployment process? Our DevOps experts can help you implement robust CI/CD solutions.

Get CI/CD Help