CI/CD Pipelines with GitHub Actions: Complete Guide for Node.js Projects
Sabin Shrestha
Full-Stack Developer
Continuous Integration and Continuous Deployment (CI/CD) automates your software delivery process. GitHub Actions makes this accessible with powerful workflows defined in YAML.
GitHub Actions Fundamentals
Workflow Structure
# .github/workflows/ci.yml
name: CI Pipeline
# Triggers
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
# Environment variables
env:
NODE_VERSION: '20'
# Jobs run in parallel by default
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: npm ci
- run: npm test
Key Concepts
- Workflow - Automated process defined in YAML
- Job - Set of steps running on the same runner
- Step - Individual task (action or shell command)
- Action - Reusable unit of code
- Runner - Server that runs workflows
Complete CI Pipeline
Here's a production-ready CI pipeline:
# .github/workflows/ci.yml
name: CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
NODE_VERSION: '20'
jobs:
# Job 1: Code Quality
lint:
name: Lint & Format Check
runs-on: ubuntu-latest
steps:
- 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: Run ESLint
run: npm run lint
- name: Check formatting
run: npm run format:check
# Job 2: Type Checking
typecheck:
name: TypeScript Check
runs-on: ubuntu-latest
steps:
- 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: Type check
run: npm run typecheck
# Job 3: Unit Tests
test-unit:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- 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: Run unit tests
run: npm run test:unit -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/lcov.info
# Job 4: Integration Tests
test-integration:
name: Integration Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- 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: Run database migrations
run: npm run db:migrate
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
- name: Run integration tests
run: npm run test:integration
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
REDIS_URL: redis://localhost:6379
# Job 5: E2E Tests
test-e2e:
name: E2E Tests
runs-on: ubuntu-latest
steps:
- 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: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run E2E tests
run: npm run test:e2e
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
# Job 6: Build Check
build:
name: Build
runs-on: ubuntu-latest
needs: [lint, typecheck, test-unit]
steps:
- 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: Build application
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build
path: dist/
retention-days: 7
CD Pipeline with Docker
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
tags: ['v*']
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
name: Build & Push Docker Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image_tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_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=
- name: Build and push
uses: docker/build-push-action@v5
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-staging:
name: Deploy to Staging
needs: build-and-push
runs-on: ubuntu-latest
environment: staging
steps:
- name: Deploy to staging server
uses: appleboy/[email protected]
with:
host: ${{ secrets.STAGING_HOST }}
username: ${{ secrets.STAGING_USER }}
key: ${{ secrets.STAGING_SSH_KEY }}
script: |
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main
docker stop app || true
docker rm app || true
docker run -d \
--name app \
--restart unless-stopped \
-p 3000:3000 \
-e DATABASE_URL="${{ secrets.DATABASE_URL }}" \
-e NODE_ENV=staging \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main
deploy-production:
name: Deploy to Production
needs: [build-and-push, deploy-staging]
runs-on: ubuntu-latest
environment: production
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Deploy to production
uses: appleboy/[email protected]
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: |
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
docker stop app || true
docker rm app || true
docker run -d \
--name app \
--restart unless-stopped \
-p 3000:3000 \
-e DATABASE_URL="${{ secrets.DATABASE_URL }}" \
-e NODE_ENV=production \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
Secrets Management
Repository Secrets
Store sensitive data in repository secrets:
# Access secrets in workflows
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_KEY: ${{ secrets.API_KEY }}
Environment-Specific Secrets
Use environments for different deployment targets:
jobs:
deploy:
environment: production # Uses production secrets
steps:
- name: Deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }} # From production environment
OpenID Connect (OIDC)
Avoid long-lived credentials with OIDC:
jobs:
deploy-aws:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/GitHubActionsRole
aws-region: us-east-1
- name: Deploy to AWS
run: aws s3 sync ./dist s3://my-bucket
Matrix Builds
Test across multiple versions:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
fail-fast: false
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
Caching Strategies
npm Cache
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # Automatic npm caching
Custom Cache
- name: Cache node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
Docker Layer Cache
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
Reusable Workflows
Create reusable workflows for consistency:
# .github/workflows/reusable-test.yml
name: Reusable Test Workflow
on:
workflow_call:
inputs:
node-version:
required: false
type: string
default: '20'
secrets:
codecov-token:
required: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test -- --coverage
- name: Upload coverage
if: ${{ secrets.codecov-token }}
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.codecov-token }}
Use the reusable workflow:
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
uses: ./.github/workflows/reusable-test.yml
with:
node-version: '20'
secrets:
codecov-token: ${{ secrets.CODECOV_TOKEN }}
Notifications
Slack Notifications
- name: Notify Slack on failure
if: failure()
uses: slackapi/[email protected]
with:
payload: |
{
"text": "❌ Deployment failed for ${{ github.repository }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Build Failed*\nRepo: ${{ github.repository }}\nBranch: ${{ github.ref_name }}\nCommit: ${{ github.sha }}"
}
}
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
Best Practices
1. Fail Fast
strategy:
fail-fast: true # Stop all jobs if one fails
2. Use Concurrency
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true # Cancel previous runs
3. Set Timeouts
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15 # Prevent hung jobs
4. Use Specific Action Versions
# Good: pinned version
- uses: actions/checkout@v4
# Bad: mutable tag
- uses: actions/checkout@main
Conclusion
A robust CI/CD pipeline:
- Runs on every push - Catch issues early
- Tests thoroughly - Unit, integration, E2E
- Builds efficiently - Use caching
- Deploys safely - Environments and approvals
- Notifies clearly - Know when things break
Start simple and iterate. A working pipeline you'll use beats a complex one you won't maintain.
Related Articles
Docker Multi-Stage Builds for Production Node.js Applications
Optimize your Docker images with multi-stage builds. Learn techniques to reduce image size, improve security, and speed up deployments for Node.js applications.
AWS Infrastructure as Code with Terraform: A Practical Guide
Learn how to manage AWS infrastructure using Terraform. This guide covers VPCs, EC2, RDS, and S3 with real-world examples and best practices for team collaboration.