Follow Us

CodeWithSabir

  • Contact Us
  • Privacy Policy
  • About
  • Terms & Conditions

All Rights Reserved © 2026

  • Light
  • Dark
DevOps

CI/CD Pipeline with GitHub Actions and Docker: A Complete Guide

Sabir Soft
Sabir Lkhaloufi
  • March 25, 2026
  • 5 min read

CI/CD Pipeline with GitHub Actions and Docker: A Complete Guide

Every time a developer on your team pushes code and manually SSHes into a server to deploy, a little piece of your reliability dies. Manual deployments are how outages happen, how bugs slip into production without going through tests, and how "it works on my machine" becomes an emergency at 2am.

This guide walks you through building a real CI/CD pipeline using GitHub Actions and Docker — the combination that powers most small-to-medium teams in 2026. By the end you'll have automated testing, automated Docker builds, and automatic deployment on every merge to main.

What We're Building

Here's the pipeline we'll create:

  1. On every pull request → run tests + lint
  2. On merge to main → build Docker image, push to registry, deploy to server
  3. On failure → notify via Slack/email (optional but important)

Prerequisites

  • A GitHub repository
  • A server (VPS, EC2, DigitalOcean Droplet — anything with Docker installed)
  • A Docker registry (Docker Hub, GitHub Container Registry, or AWS ECR)

Understanding GitHub Actions Basics

GitHub Actions uses YAML workflow files stored in .github/workflows/. Each workflow has:

  • Triggers — what causes it to run (push, pull_request, etc.)
  • Jobs — parallel or sequential groups of steps
  • Steps — individual commands or pre-built actions
# .github/workflows/ci.yml
name: CI
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Use Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm test

This is the minimum — checkout code, set up Node, install dependencies, run tests.

Step 1: The CI Workflow (Test on Every PR)

Let's build a proper test workflow for a Node.js application:

# .github/workflows/ci.yml
name: CI
 
on:
  pull_request:
    branches: [main, develop]
 
jobs:
  test:
    runs-on: ubuntu-latest
    
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: testuser
          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: 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 type check
        run: npm run type-check
      
      - name: Run tests
        env:
          DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
          NODE_ENV: test
        run: npm test -- --coverage
      
      - name: Upload coverage report
        uses: codecov/codecov-action@v4
        if: always()
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

Notice the services section — GitHub Actions lets you spin up Docker containers (like a Postgres database) as part of your job. This means your tests run against a real database, not a mock.

Step 2: Dockerizing Your Application

Before building a CD pipeline, your app needs a proper Dockerfile.

# Dockerfile
# Stage 1: Build
FROM node:20-alpine AS builder
 
WORKDIR /app
 
COPY package*.json ./
RUN npm ci --only=production
 
COPY . .
RUN npm run build
 
# Stage 2: Production
FROM node:20-alpine AS runner
 
WORKDIR /app
 
# Create non-root user for security
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 appuser
 
COPY --from=builder --chown=appuser:nodejs /app/dist ./dist
COPY --from=builder --chown=appuser:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:nodejs /app/package.json ./
 
USER appuser
 
EXPOSE 3000
 
ENV NODE_ENV=production
 
CMD ["node", "dist/index.js"]

Key decisions in this Dockerfile:

  • Multi-stage build — the builder stage includes dev dependencies; the runner stage doesn't
  • Non-root user — never run containers as root in production
  • Alpine base — smaller image, faster pulls

Test your Dockerfile locally first:

docker build -t myapp:test .
docker run -p 3000:3000 --env-file .env myapp:test

Step 3: The CD Workflow (Build and Deploy on Merge)

Now the main event — the deployment pipeline:

# .github/workflows/cd.yml
name: CD
 
on:
  push:
    branches: [main]
 
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    
    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Log in to GitHub 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=ref,event=branch
            latest
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      
      - 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:
    needs: build-and-push
    runs-on: ubuntu-latest
    
    steps:
      - name: Deploy to server
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: |
            echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
            docker pull ghcr.io/${{ github.repository }}:latest
            docker stop myapp || true
            docker rm myapp || true
            docker run -d \
              --name myapp \
              --restart unless-stopped \
              -p 3000:3000 \
              --env-file /opt/myapp/.env \
              ghcr.io/${{ github.repository }}:latest
            docker system prune -f

Setting Up Secrets

Go to your GitHub repo → Settings → Secrets and variables → Actions. Add:

  • SERVER_HOST — your server's IP or domain
  • SERVER_USER — SSH username (usually ubuntu or root)
  • SERVER_SSH_KEY — your private SSH key (the full contents of ~/.ssh/id_rsa)

For the Docker registry, GITHUB_TOKEN is automatically available — no setup needed for GitHub Container Registry.

Step 4: Using Docker Compose on the Server

For more complex deployments with multiple services (app + nginx + database), use Docker Compose:

# /opt/myapp/docker-compose.yml on your server
version: '3.8'
 
services:
  app:
    image: ghcr.io/yourusername/myapp:latest
    restart: unless-stopped
    env_file: .env
    ports:
      - "3000:3000"
    depends_on:
      - postgres
    
  postgres:
    image: postgres:16
    restart: unless-stopped
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: ${DB_NAME}
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
  
  nginx:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf
      - /etc/letsencrypt:/etc/letsencrypt:ro
    depends_on:
      - app
 
volumes:
  pgdata:

Update the deploy step to use Compose:

# In your deploy script
cd /opt/myapp
docker compose pull app
docker compose up -d --no-deps app
docker system prune -f

Step 5: Rollback Strategy

Every good CD pipeline has a rollback path. With Docker and SHA-tagged images, rollbacks are trivial:

# On your server, rollback to a previous version
docker stop myapp
docker rm myapp
docker run -d \
  --name myapp \
  --restart unless-stopped \
  -p 3000:3000 \
  ghcr.io/yourusername/myapp:sha-abc1234

Better yet, add a GitHub Actions workflow triggered manually:

# .github/workflows/rollback.yml
name: Rollback
 
on:
  workflow_dispatch:
    inputs:
      image_tag:
        description: 'Docker image tag to rollback to (e.g., sha-abc1234)'
        required: true
 
jobs:
  rollback:
    runs-on: ubuntu-latest
    steps:
      - name: Rollback deployment
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: |
            docker pull ghcr.io/${{ github.repository }}:${{ inputs.image_tag }}
            docker stop myapp || true
            docker rm myapp || true
            docker run -d --name myapp --restart unless-stopped \
              -p 3000:3000 --env-file /opt/myapp/.env \
              ghcr.io/${{ github.repository }}:${{ inputs.image_tag }}

Common Mistakes

1. Storing secrets in code. Use GitHub Secrets, never commit .env files.

2. Running containers as root. Always add a non-root user in your Dockerfile.

3. Not caching Docker layers. The cache-from: type=gha in the build step is critical for fast builds.

4. Not health-checking the deployment. After deploying, verify the app is actually running:

# Add to deploy script
sleep 5
if ! curl -sf http://localhost:3000/health; then
  echo "Health check failed — rolling back"
  exit 1
fi

5. Single-environment pipelines. Add a staging environment that deploys on PR to main — catch issues before they hit production.

Key Takeaways

  • GitHub Actions + Docker is the most practical CI/CD stack for most teams in 2026
  • Use multi-stage Dockerfiles to keep production images small and secure
  • Always run a real database in your CI tests — mocks hide too many bugs
  • Tag images with git SHAs so you can always roll back to any previous version
  • GitHub Container Registry is free for public repos and very affordable for private ones
  • Health checks after deployment are non-negotiable for production pipelines
Popular Blogs
Claude AI vs ChatGPT: An Honest Comparison for Developers
  • April 28, 2026
AI Tools Every Developer Should Be Using in 2026
  • April 20, 2026
Using the Claude API in Real Projects: A Practical Developer Guide
  • April 15, 2026
Prompt Engineering for Developers: Write Prompts That Actually Work
  • April 10, 2026
Categories
AIDevOpsNext.jsMobile DevelopmentWeb Development

Related Posts

DevOps
Docker for Developers: From Zero to Production-Ready
Sabir Khaloufi·Mar 20, 2026
DevOps
Deploying Next.js Apps on AWS EC2: A Step-by-Step Guide
Sabir Khaloufi·Mar 15, 2026
DevOps
Kubernetes Basics Every Developer Should Know
Sabir Khaloufi·Mar 10, 2026