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:
- Go to Settings → Branches → Add rule for
main - Enable "Require status checks to pass before merging"
- Select the
testjob as required - Enable "Require linear history" (cleaner git log)
Step 5: Secrets Management
Add these in Settings → Secrets → Actions:
SERVER_HOST— your server IPSERVER_USER— SSH usernameSSH_PRIVATE_KEY— private key contentCODECOV_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=ghafor 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.