FR EN
Technical Guide — DevOps

CI/CD Pipeline
From commit to server

Automate tests, Docker build and zero-downtime deployment with GitHub Actions — from Git push to production in minutes.

AuthorSOL-IT Solutions Informatiques
Version1.0 — 2026
LevelIntermediate
Stack coveredGitHub Actions · Docker · VPS · Kubernetes

Table of Contents

  1. Overview and CI/CD Philosophy Principles, flow, environments
  2. Project Structure and Prerequisites Directory tree, GitHub secrets
  3. CI Pipeline — Tests and build Lint, unit tests, Docker build
  4. VPS Deployment (zero-downtime) SSH, Docker Compose, health check
  5. Kubernetes Deployment kubectl, rollout, rollback
  6. Environment Management dev, staging, production
  7. Notifications and Observability Slack, email, status badges
  8. Setup Checklist Final checkpoints
Chapter 1

Overview and CI/CD Philosophy

What is CI/CD?

CI (Continuous Integration): every code push automatically triggers tests and the build, ensuring that integrated code doesn't break anything.

CD (Continuous Deployment): if CI passes, the code is automatically deployed to production (or staging), without manual intervention.

The Complete Flow

💻
git push
Developer
🔍
Lint + Tests
CI Runner
🐳
Docker Build
Registry
🚀
Deploy
VPS / K8s
Health Check
Monitoring
🔔
Notification
Slack / Email

The Three Environments

  • Developmentdevelop branch, automatically deployed to a test server
  • Stagingstaging branch, production mirror for final validation
  • Productionmain branch, deployment after manual approval (approval gate)
💡 Golden rule: The pipeline must fail fast. If tests don't pass, deployment doesn't happen, regardless of urgency.
Chapter 2

Project Structure and Prerequisites

Recommended Directory Structure

# Your repository structure
my-project/
├── .github/
│   └── workflows/
│       ├── ci.yml          # Tests + build (on all pushes)
│       ├── deploy-staging.yml   # Deploy staging (staging branch)
│       └── deploy-prod.yml      # Deploy production (main branch)
├── docker/
│   ├── Dockerfile
│   └── nginx.conf
├── docker-compose.yml          # Local composition
├── docker-compose.prod.yml     # Production composition
└── src/

GitHub Secrets to Configure

In your GitHub repository → Settings → Secrets and variables → Actions:

PROD_SSH_HOST PROD_SSH_USER PROD_SSH_KEY REGISTRY_TOKEN SLACK_WEBHOOK_URL APP_ENV_PROD

Generate and Configure the Deploy SSH Key

# On your local machine: generate a dedicated CI key
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key -N ""

# Copy the public key to the VPS
ssh-copy-id -i ~/.ssh/deploy_key.pub deployer@your-vps.com

# Add the private key to GitHub Secrets
# PROD_SSH_KEY = content of ~/.ssh/deploy_key (PRIVATE key)
cat ~/.ssh/deploy_key
⚠️ The private SSH key goes into GitHub Secrets (encrypted). Never commit it in code.
Chapter 3

CI Pipeline — Tests and Build

.github/workflows/ci.yml

 CI — Tests & Build


  push:
    branches: ['**']   # On all branches
  pull_request:
    branches: [main, staging]


  test:
    name: Tests & Lint
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: test_db
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: secret
        options: >-
          --health-cmd pg_isready
          --health-interval 5s
          --health-timeout 3s
          --health-retries 5
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup PHP 8.3
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: pdo_pgsql, redis, bcmath
          coverage: xdebug

      - name: Install Composer dependencies
        run: composer install --no-interaction --prefer-dist --optimize-autoloader

      - name: Prepare Laravel environment
        run: |
          cp .env.testing .env
          php artisan key:generate
          php artisan migrate --force

      - name: Run PHPUnit tests
        run: php artisan test --parallel --coverage-clover coverage.xml

      - name: PHP CS Fixer (Lint)
        run: vendor/bin/php-cs-fixer fix --dry-run --diff

  build:
    name: Build & Push Docker image
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging'
    steps:
      - uses: actions/checkout@v4

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max
💡 Docker layer cachecache-from/cache-to: type=gha uses GitHub Actions cache to speed up repeated builds by 60–80%.
Chapter 4

VPS Deployment — Zero-downtime

docker-compose.prod.yml


  app:
    image: ghcr.io/myorg/my-app:${IMAGE_TAG}
    restart: unless-stopped
    env_file: .env.production
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.app.rule=Host(`app.example.com`)"
      - "traefik.http.routers.app.tls.certresolver=letsencrypt"

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"]
      interval: 5s


  pgdata:

.github/workflows/deploy-prod.yml

 Deploy — Production


  push:
    branches: [main]


  deploy:
    name: Deploy to VPS (zero-downtime)
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://app.example.com
    steps:
      - uses: actions/checkout@v4

      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PROD_SSH_HOST }}
          username: ${{ secrets.PROD_SSH_USER }}
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            # Pull the new image
            docker pull ghcr.io/${{ github.repository }}:${{ github.sha }}

            # Zero-downtime update with Docker Compose
            export IMAGE_TAG=${{ github.sha }}
            docker compose -f /opt/my-app/docker-compose.prod.yml up -d --no-deps app

            # Wait for health check
            echo "Waiting for health check..."
            timeout 60 bash -c 'until docker inspect --format="{{.State.Health.Status}}" my-app-app-1 | grep -q healthy; do sleep 3; done'

            # Laravel migrations
            docker compose -f /opt/my-app/docker-compose.prod.yml exec -T app php artisan migrate --force

            # Clean up old images
            docker image prune -f

            echo "✅ Deployment successful: ${{ github.sha }}"
Zero-downtime guaranteed: Docker Compose recreates only the app container, the database and volumes remain intact. The health check confirms the new container is responding before marking the deployment successful.

Rollback in a Single Commit

# In case of issue, revert to the previous commit
git revert HEAD --no-edit
git push origin main
# The pipeline relaunches automatically with the previous version
Chapter 5

Kubernetes Deployment

Complete GitHub Actions → K8s Workflow

 Deploy — Kubernetes


  push:
    branches: [main]


  deploy-k8s:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Configure kubectl
        uses: azure/k8s-set-context@v3
        with:
          kubeconfig: ${{ secrets.KUBECONFIG }}

      - name: Inject image tag into manifests
        run: sed -i "s|IMAGE_TAG|${{ github.sha }}|g" k8s/deployment.yaml

      - name: Apply manifests
        run: kubectl apply -f k8s/ -n production

      - name: Wait for rollout
        run: kubectl rollout status deployment/my-app -n production --timeout=120s

      - name: Run post-deploy migrations
        run: |
          kubectl exec deployment/my-app -n production -- php artisan migrate --force

      - name: Rollback on failure
        if: failure()
        run: kubectl rollout undo deployment/my-app -n production
💡 The Rollback on failure step runs automatically if a previous step fails, thanks to if: failure().
Chapter 6

Environment Management

Recommended Branch Strategy

# Simplified Gitflow for CI/CD

main        →  Production  (protected, PR required, approval gate)
staging     →  Staging     (automatic deployment)
develop     →  Dev         (automatic deployment)
feature/*   →  CI only     (tests + build, no deployment)

Environment Variables per Env

# .env.staging
APP_ENV=staging
APP_URL=https://staging.example.com
DB_DATABASE=app_staging
LOG_LEVEL=debug
CACHE_DRIVER=redis
---
# .env.production
APP_ENV=production
APP_URL=https://app.example.com
APP_DEBUG=false
LOG_LEVEL=error
CACHE_DRIVER=redis

Production Approval Gate

In GitHub → Settings → Environments → production: enable Required reviewers. Production deployment can only start after manual approval from a responsible team member.

⚠️ Never deploy directly to production from a feature branch. Always go through staging first.
Chapter 7

Notifications and Observability

Slack Notification on Success/Failure

# Add at the end of workflow
      - name: Notify Slack — Success
        if: success()
        uses: slackapi/slack-github-action@v1
        with:
          channel-id: '#deployments'
          slack-message: |
            ✅ *${{ github.repository }}* deployed to production
            Commit: `${{ github.sha }}` by @${{ github.actor }}
            ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
        env:
          SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}

      - name: Notify Slack — Failure
        if: failure()
        uses: slackapi/slack-github-action@v1
        with:
          channel-id: '#deployments'
          slack-message: |
            🚨 *DEPLOYMENT FAILED* — ${{ github.repository }}
            Commit: `${{ github.sha }}` — automatic rollback triggered
        env:
          SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}

Status Badge in README

# Add to README.md
![CI](https://github.com/myorg/my-repo/actions/workflows/ci.yml/badge.svg)
![Deploy](https://github.com/myorg/my-repo/actions/workflows/deploy-prod.yml/badge.svg)
Chapter 8

Setup Checklist

Before Enabling the Pipeline

  • GitHub Secrets configured: SSH key, registry tokens, production env variables
  • main branch protected: PR required, CI must pass before merge
  • .env.testing file present and working for CI tests
  • /health route implemented in the application (returns 200 if OK)
  • Health check configured in docker-compose.prod.yml
  • Approval gate enabled on the GitHub production environment
  • Rollback tested manually: kubectl rollout undo or Git revert
  • Slack or email notification configured on failure
  • Centralized logs accessible post-deployment
  • Pipeline tested on a feature branch before enabling on main

Post-Deployment Verification Commands

# Check container status
docker compose -f docker-compose.prod.yml ps

# Real-time logs
docker compose -f docker-compose.prod.yml logs -f app --tail=50

# Manual health check
curl -f https://app.example.com/health && echo "✅ OK" || echo "❌ FAIL"

# Verify deployed version
curl https://app.example.com/health | jq '.version'