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
- Development —
developbranch, automatically deployed to a test server - Staging —
stagingbranch, production mirror for final validation - Production —
mainbranch, 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
name: CI — Tests & Build on: push: branches: ['**'] # On all branches pull_request: branches: [main, staging] jobs: 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 cache —
cache-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
services: 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 volumes: pgdata:
.github/workflows/deploy-prod.yml
name: Deploy — Production on: push: branches: [main] jobs: 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
name: Deploy — Kubernetes on: push: branches: [main] jobs: 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


Chapter 8
Setup Checklist
Before Enabling the Pipeline
- GitHub Secrets configured: SSH key, registry tokens, production env variables
mainbranch protected: PR required, CI must pass before merge.env.testingfile present and working for CI tests/healthroute implemented in the application (returns 200 if OK)- Health check configured in
docker-compose.prod.yml - Approval gate enabled on the GitHub
productionenvironment - Rollback tested manually:
kubectl rollout undoor 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'
