DEV Community

ahmet gedik
ahmet gedik

Posted on

CI/CD Pipeline for a Multi-Site Video Platform

Deploying TopVideoHub means pushing to 4 LiteSpeed servers that each cover different Asia-Pacific regions. Manual FTP to 4 hosts is error-prone. GitHub Actions turned that into a one-push workflow.

Here is the full pipeline.

Constraints: Shared Hosting Reality

Shared LiteSpeed hosting does not give you SSH access, Docker runners on the server, or webhook endpoints. The deployment method is FTP via lftp. The CI pipeline must:

  1. Run tests and lint PHP without a server
  2. Deploy via FTP in parallel to all 4 hosts
  3. Clear the LiteSpeed page cache after deploy
  4. Send a notification when all hosts are live

Repository Secrets

In GitHub Settings → Secrets and variables → Actions, create one secret per host:

FTP_DWV_HOST=ftp.dailywatch.video
FTP_DWV_USER=deploy_user
FTP_DWV_PASS=super_secret
FTP_DWV_PATH=/htdocs

FTP_TVH_HOST=ftp.topvideohub.com
FTP_TVH_USER=deploy_user
FTP_TVH_PASS=super_secret
FTP_TVH_PATH=/htdocs

# ... repeat for tvs and vvv
Enter fullscreen mode Exit fullscreen mode

Main Workflow

# .github/workflows/deploy.yml
name: Test and Deploy

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  # ─── Phase 1: Test ───────────────────────────────────────────
  test:
    name: PHP Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: pdo_sqlite, gd, curl
          coverage: none

      - name: Validate composer.json
        run: composer validate --strict

      - name: Install dependencies
        run: composer install --no-dev --optimize-autoloader

      - name: PHP Lint (syntax check)
        run: |
          find app/ public/ cron/ templates/ \
            -name '*.php' \
            -exec php -l {} \; | grep -v 'No syntax errors'

      - name: Run unit tests
        run: vendor/bin/phpunit tests/ --testdox

      - name: Validate .htaccess syntax
        run: |
          if grep -P '\t' public/.htaccess; then
            echo "ERROR: .htaccess contains tab characters"
            exit 1
          fi
          echo ".htaccess OK"

      - name: Check deploy_hosts.conf line endings
        run: |
          if grep -Pc '\r' deploy_hosts.conf > /dev/null 2>&1; then
            echo "ERROR: deploy_hosts.conf has Windows line endings!"
            exit 1
          fi
          echo "deploy_hosts.conf OK"

  # ─── Phase 2: Deploy (only on push to main) ───────────────────
  deploy:
    name: Deploy — ${{ matrix.site }}
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false          # Don't cancel other deploys if one fails
      matrix:
        site: [dwv, tvh, tvs, vvv]

    steps:
      - uses: actions/checkout@v4

      - name: Install lftp
        run: sudo apt-get install -y lftp

      - name: Deploy via FTP
        env:
          FTP_HOST: ${{ secrets[format('FTP_{0}_HOST', matrix.site)] }}
          FTP_USER: ${{ secrets[format('FTP_{0}_USER', matrix.site)] }}
          FTP_PASS: ${{ secrets[format('FTP_{0}_PASS', matrix.site)] }}
          FTP_PATH: ${{ secrets[format('FTP_{0}_PATH', matrix.site)] }}
        run: |
          lftp -u "$FTP_USER","$FTP_PASS" "$FTP_HOST" << 'LFTP'
            set ssl:verify-certificate no
            set net:timeout 60
            set net:max-retries 3
            mirror -R --verbose --ignore-time \
              --exclude .git/ \
              --exclude .github/ \
              --exclude .env \
              --exclude 'data/' \
              --exclude '*.log' \
              --exclude '*.db' \
              --exclude 'backlink/' \
              ./ $FTP_PATH
            put api_keys.conf -o $FTP_PATH/api_keys.conf
            rm -rf $FTP_PATH/lscache
            quit
          LFTP

      - name: Verify deploy
        env:
          FTP_HOST: ${{ secrets[format('FTP_{0}_HOST', matrix.site)] }}
          FTP_USER: ${{ secrets[format('FTP_{0}_USER', matrix.site)] }}
          FTP_PASS: ${{ secrets[format('FTP_{0}_PASS', matrix.site)] }}
          FTP_PATH: ${{ secrets[format('FTP_{0}_PATH', matrix.site)] }}
        run: |
          REMOTE_VER=$(lftp -u "$FTP_USER","$FTP_PASS" "$FTP_HOST" -e \
            "set ssl:verify-certificate no; cat $FTP_PATH/version.txt; quit")
          LOCAL_VER=$(cat version.txt)
          if [ "$REMOTE_VER" = "$LOCAL_VER" ]; then
            echo "[${{ matrix.site }}] Verify PASSED: $REMOTE_VER"
          else
            echo "[${{ matrix.site }}] Verify FAILED: remote=$REMOTE_VER local=$LOCAL_VER"
            exit 1
          fi

  # ─── Phase 3: Post-deploy notification ───────────────────────
  notify:
    name: Notify
    needs: deploy
    if: always()
    runs-on: ubuntu-latest
    steps:
      - name: Post status to Slack
        uses: slackapi/slack-github-action@v1.26.0
        with:
          payload: |
            {
              "text": "TopVideoHub deploy ${{ needs.deploy.result == 'success' && '✓ succeeded' || '✗ failed' }} on `${{ github.ref_name }}` by ${{ github.actor }}"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
Enter fullscreen mode Exit fullscreen mode

PHP Unit Test Example

<?php
// tests/QuotaManagerTest.php

use PHPUnit\Framework\TestCase;

class QuotaManagerTest extends TestCase
{
    private PDO $db;

    protected function setUp(): void
    {
        // In-memory SQLite for tests — no file I/O
        $this->db = new PDO('sqlite::memory:');
        $this->db->exec(file_get_contents(__DIR__ . '/../schema.sql'));
    }

    public function testQuotaDecrement(): void
    {
        $mgr = new QuotaManager($this->db);
        $mgr->setQuota('key1', 10000);

        $mgr->consume('key1', 100);
        $this->assertSame(9900, $mgr->remaining('key1'));
    }

    public function testKeyRotation(): void
    {
        $mgr = new QuotaManager($this->db);
        $mgr->setQuota('key1', 0);   // Exhausted
        $mgr->setQuota('key2', 10000);

        $activeKey = $mgr->getActiveKey();
        $this->assertSame('key2', $activeKey);
    }

    public function testRegionFetchOrder(): void
    {
        $regions = ['US', 'GB', 'JP', 'KR', 'TW', 'SG', 'VN', 'TH', 'HK'];
        $this->assertCount(9, $regions);
        $this->assertContains('JP', $regions);
        $this->assertContains('KR', $regions);
    }
}
Enter fullscreen mode Exit fullscreen mode

Caching the Composer Install

- name: Cache Composer packages
  uses: actions/cache@v4
  with:
    path: vendor
    key: composer-${{ hashFiles('composer.lock') }}
    restore-keys: composer-

- name: Install dependencies
  run: composer install --no-dev --optimize-autoloader
Enter fullscreen mode Exit fullscreen mode

Parallel Deploy Performance

The matrix strategy runs all 4 deployments simultaneously. Wall-clock time for a full deploy to all 4 LiteSpeed hosts:

Step Time
PHP tests + lint ~45s
FTP mirror (parallel × 4) ~3min
Verify + notify ~15s
Total ~4min

Without parallelism, sequential FTP to 4 hosts would take ~12 minutes.

Branch Protection

# .github/branch_protection.yml (via GitHub API or UI)
branch: main
required_status_checks:
  strict: true
  contexts:
    - "PHP Tests"
require_pull_request_reviews:
  required_approving_review_count: 1
restrict_pushes: true
Enter fullscreen mode Exit fullscreen mode

Every commit to TopVideoHub now goes through this pipeline. The matrix deploy means all 4 servers are updated in under 4 minutes from a git push.


This is part of the "Building TopVideoHub" series, documenting the architecture behind a video discovery platform covering 9 Asia-Pacific regions.

Top comments (0)