CodeWithSabir
HomeAIDevOpsNext.jsMobile DevelopmentWeb Development
CodeWithSabir
  • Home
  • AI
  • DevOps
  • Next.js
  • Mobile Development
  • Web Development
  • About
  • Contact
CodeWithSabir

In-depth articles, tutorials, and guides on web development, React, Next.js, AI, and modern programming practices.

Topics

  • AI
  • DevOps
  • Next.js
  • Mobile Development
  • Web Development

Company

  • About
  • Contact
  • Privacy Policy
  • Terms

© 2026 CodeWithSabir. All rights reserved.

Built with SabirSoft.com

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

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

Learn how to build a production-ready CI/CD pipeline using GitHub Actions and Docker. Covers testing, building images, pushing to registries, and deploying automatically.

Sabir KhaloufiSabir KhaloufiMarch 25, 20265 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
yaml
# .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:

yaml
# .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
# 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:

bash
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:

yaml
# .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:

yaml
# /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:

bash
# 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:

bash
# 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:

yaml
# .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:

bash
# 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
#ci/cd#github actions#docker#devops#automation
Share:
Sabir Khaloufi — author photo

Written by

Sabir Khaloufi

Full-stack developer and tech blogger sharing in-depth tutorials on React, Next.js, AI, and modern web development.

On this page

  • What We're Building
  • Prerequisites
  • Understanding GitHub Actions Basics
  • Step 1: The CI Workflow (Test on Every PR)
  • Step 2: Dockerizing Your Application
  • Step 3: The CD Workflow (Build and Deploy on Merge)
  • Setting Up Secrets
  • Step 4: Using Docker Compose on the Server
  • Step 5: Rollback Strategy
  • Common Mistakes
  • Key Takeaways

Related Articles

DevOps

Docker for Developers: From Zero to Production-Ready

A practical Docker guide for developers — from understanding containers to running multi-service apps with Docker Compose and preparing images for production deployment.

Sabir KhaloufiMarch 20, 20264 min read
DevOps

Deploying Next.js Apps on AWS EC2: A Step-by-Step Guide

Learn how to deploy a Next.js application on AWS EC2 from scratch — covering server setup, Nginx reverse proxy, SSL certificates, PM2, and automated deployments.

Sabir KhaloufiMarch 15, 20264 min read
DevOps

Kubernetes Basics Every Developer Should Know

A practical Kubernetes introduction for developers — covering Pods, Deployments, Services, ConfigMaps, and the core concepts you need to deploy and manage containerized apps.

Sabir KhaloufiMarch 10, 20264 min read