DEV Community

ahmet gedik
ahmet gedik

Posted on

CI/CD Pipeline for a Multi-Site Video Platform

Deploying a video platform to four separate hosting environments used to mean running a script manually and hoping nothing went wrong. Here's how I replaced that with a GitHub Actions CI/CD pipeline for DailyWatch and its sibling sites.

The Problem

Four sites, each on LiteSpeed shared hosting. Same codebase, different configurations (.env files, API keys, region settings). Deployments were FTP-based — no SSH, no Docker on the hosts, no Kubernetes. Just good old file transfer.

Manual deploys meant:

  • Forgetting to deploy to one site
  • Deploying stale code after switching branches
  • No rollback mechanism

GitHub Actions Workflow Structure

The workflow triggers on pushes to main and runs three stages: lint, test, deploy.

# .github/workflows/deploy.yml
name: Deploy Multi-Site

on:
  push:
    branches: [main]
  workflow_dispatch:  # Manual trigger

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: sqlite3, pdo_sqlite, curl
      - name: PHP Lint
        run: find app/ public/ -name '*.php' -exec php -l {} \;
      - name: Check .htaccess syntax
        run: |
          apache2ctl -t -f /dev/null -c "Include $(pwd)/public/.htaccess" 2>&1 || true

  test:
    needs: lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: sqlite3, pdo_sqlite, curl, zip
      - name: Run Tests
        run: |
          php tests/run.php
        env:
          APP_ENV: testing

  deploy:
    needs: test
    runs-on: ubuntu-latest
    strategy:
      matrix:
        site: [dwv, tvh, vvv, tvs]
      fail-fast: false  # Don't cancel other deploys if one fails
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to ${{ matrix.site }}
        uses: SamKirkland/FTP-Deploy-Action@v4.3.5
        with:
          server: ${{ secrets[format('{0}_FTP_HOST', matrix.site)] }}
          username: ${{ secrets[format('{0}_FTP_USER', matrix.site)] }}
          password: ${{ secrets[format('{0}_FTP_PASS', matrix.site)] }}
          server-dir: /public_html/
          exclude: |
            **/.env
            **/data/**
            **/*.log
            **/.git/**
            **/tests/**
Enter fullscreen mode Exit fullscreen mode

The matrix strategy runs four deploy jobs in parallel. fail-fast: false ensures a failure on one site doesn't cancel the others — if tvh fails, dwv, vvv, and tvs still deploy.

FTP Secrets Management

Each site gets three secrets in GitHub repository settings:

DWV_FTP_HOST, DWV_FTP_USER, DWV_FTP_PASS
TVH_FTP_HOST, TVH_FTP_USER, TVH_FTP_PASS
VVV_FTP_HOST, VVV_FTP_USER, VVV_FTP_PASS
TVS_FTP_HOST, TVS_FTP_USER, TVS_FTP_PASS
Enter fullscreen mode Exit fullscreen mode

The format() function in the workflow dynamically selects the right secret based on the matrix value. No hardcoded credentials in the YAML.

Post-Deploy: Cache Clearing

LiteSpeed caches aggressively. After deploy, clear it:

      - name: Clear LiteSpeed Cache
        uses: SamKirkland/FTP-Deploy-Action@v4.3.5
        with:
          server: ${{ secrets[format('{0}_FTP_HOST', matrix.site)] }}
          username: ${{ secrets[format('{0}_FTP_USER', matrix.site)] }}
          password: ${{ secrets[format('{0}_FTP_PASS', matrix.site)] }}
          server-dir: /lscache/
          dangerous-clean-slate: true  # Wipe the directory
Enter fullscreen mode Exit fullscreen mode

Alternatively, hit a cache-clear endpoint after deploy:

      - name: Trigger Cache Clear
        run: |
          SITE_URL=${{ secrets[format('{0}_URL', matrix.site)] }}
          curl -sf "${SITE_URL}/task/clear-cache?key=${{ secrets.TASK_KEY }}" || true
Enter fullscreen mode Exit fullscreen mode

Deploy Verification

Deploying is only half the job. Verify the deploy actually worked:

      - name: Verify Deployment
        run: |
          SITE_URL=${{ secrets[format('{0}_URL', matrix.site)] }}
          STATUS=$(curl -s -o /dev/null -w '%{http_code}' "${SITE_URL}")
          if [ "$STATUS" != "200" ]; then
            echo "::error::Site returned HTTP $STATUS after deploy"
            exit 1
          fi
          # Check version marker
          VERSION=$(curl -sf "${SITE_URL}/health" | jq -r '.version')
          echo "Deployed version: $VERSION"
Enter fullscreen mode Exit fullscreen mode

The health endpoint returns the current git commit hash, so you can confirm the deploy propagated.

Notifications

Add a Slack or Discord notification at the end:

  notify:
    needs: deploy
    runs-on: ubuntu-latest
    if: always()
    steps:
      - name: Send Notification
        run: |
          STATUS="success"
          if [ "${{ needs.deploy.result }}" != "success" ]; then
            STATUS="failure"
          fi
          curl -X POST "${{ secrets.DISCORD_WEBHOOK }}" \
            -H 'Content-Type: application/json' \
            -d "{\"content\": \"Deploy $STATUS for commit ${{ github.sha }}\"}"
Enter fullscreen mode Exit fullscreen mode

Lessons from Production

  • FTP is slow — The parallel matrix strategy cuts total deploy time from 12 minutes (sequential) to about 4 minutes.
  • Line endings matter — Config files must be Unix LF. A stray \r in FTP path configs corrupts remote paths silently.
  • Exclude patterns are critical — Never deploy .env files or data/ directories. One bad deploy can wipe your production database.

The pipeline has handled 200+ deploys to DailyWatch and the other sites without a manual intervention. It's not fancy, but it's reliable.


This article is part of the Building DailyWatch series.

Top comments (0)