Back to Blog
DevOps

GitHub Actions CI/CD Pipeline for Node.js and Docker: Zero to Production

February 14, 202613 min read

Set up a complete CI/CD pipeline with GitHub Actions, Docker, and automated deployments. Covers testing, building, pushing to a registry, and rolling deploys.

GitHub Actions CI/CD Pipeline for Node.js and Docker: Zero to Production

Introduction

A CI/CD pipeline is the nervous system of modern software delivery. Every code push should automatically run tests, build a production artifact, and deploy — without human intervention. This guide walks through a complete, production-grade pipeline using GitHub Actions, Docker, and a container registry.


Pipeline Overview

Push to main
  → Run tests (Jest + ESLint)
  → Build Docker image
  → Push to GitHub Container Registry (ghcr.io)
  → Deploy to server via SSH
  → Health check

Step 1: Project Structure

my-api/
├── src/
├── tests/
├── Dockerfile
├── .dockerignore
└── .github/
    └── workflows/
        └── ci-cd.yml

Step 2: Optimised Dockerfile

# Dockerfile
# Stage 1: Dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

# Stage 2: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 3: Production image
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

# Copy only what's needed
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./

# Non-root user for security
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nodeapp
USER nodeapp

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

Step 3: The GitHub Actions Workflow

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

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

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

jobs:
  # ── Job 1: Test ──────────────────────────────
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npm run lint

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

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

  # ── Job 2: Build & Push ──────────────────────
  build-and-push:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    permissions:
      contents: read
      packages: write

    outputs:
      image-digest: ${{ steps.build.outputs.digest }}

    steps:
      - uses: actions/checkout@v4

      - name: Log in 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=sha,prefix=sha-
            type=raw,value=latest

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

      - name: Build and push
        id: build
        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

  # ── Job 3: Deploy ────────────────────────────
  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    environment: production

    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
            docker pull ghcr.io/${{ github.repository }}:latest
            docker stop my-api || true
            docker rm my-api || true
            docker run -d \
              --name my-api \
              --restart unless-stopped \
              -p 3000:3000 \
              --env-file /etc/my-api.env \
              ghcr.io/${{ github.repository }}:latest
            docker image prune -f

      - name: Health check
        run: |
          sleep 10
          curl -f https://api.myapp.com/health || exit 1

Step 4: Branch Protection Rules

Enforce the pipeline on PRs:

  1. Go to Settings → Branches → Add rule for main
  2. Enable "Require status checks to pass before merging"
  3. Select the test job as required
  4. Enable "Require linear history" (cleaner git log)

Step 5: Secrets Management

Add these in Settings → Secrets → Actions:

  • SERVER_HOST — your server IP
  • SERVER_USER — SSH username
  • SSH_PRIVATE_KEY — private key content
  • CODECOV_TOKEN — from codecov.io

Advanced: Matrix Testing

Test across multiple Node versions in parallel:

strategy:
  matrix:
    node-version: ['18', '20', '22']
steps:
  - uses: actions/setup-node@v4
    with:
      node-version: ${{ matrix.node-version }}

Conclusion

A properly configured CI/CD pipeline eliminates entire categories of human error and makes deployment a non-event. The investment of a few hours setting this up pays back every working day.

Key takeaways:

  • Multi-stage Docker builds drastically reduce image size
  • Cache Docker layers with cache-from: type=gha for fast builds
  • Use environments in GitHub for production approval gates
  • Always run a post-deploy health check

Related: See the post on Kubernetes deployment strategies for how to extend this pipeline to rolling and canary deploys.

Tags

GitHub ActionsCI/CDDockerDevOpsNode.jsAutomation