Tools: CI/CD Pipeline for a Multi-Site Video Platform (2026)

Tools: CI/CD Pipeline for a Multi-Site Video Platform (2026)

Constraints

The Workflow

Matrix Strategy Deep Dive

Secrets Organization

Post-Deploy Verification

Notifications on Failure

Production Results ViralVidVault is part of a family of video platforms, each running on separate LiteSpeed shared hosting. Same codebase, different configurations. Deploying manually to multiple hosts was error-prone and tedious. Here's the GitHub Actions pipeline that replaced it. Shared hosting means no SSH, no Docker on the server, no git pull. The only deployment option is FTP. The pipeline needs to: The workflow_dispatch input lets you deploy a single site manually from the GitHub UI. Useful when you need to hotfix one site without touching the others. The matrix keyword creates four parallel jobs, one per site. Each job has its own runner and its own set of secrets: fail-fast: false is critical. Without it, if the tvh deploy fails (say, FTP timeout), GitHub cancels the vvv, dwv, and tvs jobs too. That turns one problem into four. Twelve repository secrets, three per site: The format() function dynamically resolves the right secret: Deploy doesn't mean working. Add a verification step: This catches silent failures — FTP uploads that seem to succeed but actually fail due to permissions, disk space, or path misconfigurations. A deploy that fails silently is worse than a deploy that fails loudly: Since switching to this pipeline for ViralVidVault and the other sites: The FTP constraint makes this less elegant than a container-based deploy, but the reliability improvement is the same. Automate the boring parts, verify the important parts. This article is part of the Building ViralVidVault series. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

# .github/workflows/deploy.yml name: Test and Deploy on: push: branches: [main] workflow_dispatch: inputs: site: description: 'Deploy specific site (or all)' required: false default: 'all' type: choice options: [all, dwv, tvh, vvv, tvs] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: '8.3' extensions: sqlite3, pdo_sqlite, -weight: 500;">curl - name: Lint PHP files run: | EXIT_CODE=0 while IFS= read -r file; do php -l "$file" > /dev/null 2>&1 || EXIT_CODE=1 done < <(find app/ public/ cron/ -name '*.php') exit $EXIT_CODE - name: Run test suite 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 steps: - uses: actions/checkout@v4 - name: Deploy via FTP 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 **/.-weight: 500;">git/** **/tests/** **/backlink/** - name: Clear LiteSpeed cache run: | -weight: 500;">curl -sf "${{ secrets[format('{0}_URL', matrix.site)] }}/task/clear-cache?key=${{ secrets.TASK_KEY }}" \ --max-time 10 || echo "Cache clear timed out (non-fatal)" # .github/workflows/deploy.yml name: Test and Deploy on: push: branches: [main] workflow_dispatch: inputs: site: description: 'Deploy specific site (or all)' required: false default: 'all' type: choice options: [all, dwv, tvh, vvv, tvs] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: '8.3' extensions: sqlite3, pdo_sqlite, -weight: 500;">curl - name: Lint PHP files run: | EXIT_CODE=0 while IFS= read -r file; do php -l "$file" > /dev/null 2>&1 || EXIT_CODE=1 done < <(find app/ public/ cron/ -name '*.php') exit $EXIT_CODE - name: Run test suite 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 steps: - uses: actions/checkout@v4 - name: Deploy via FTP 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 **/.-weight: 500;">git/** **/tests/** **/backlink/** - name: Clear LiteSpeed cache run: | -weight: 500;">curl -sf "${{ secrets[format('{0}_URL', matrix.site)] }}/task/clear-cache?key=${{ secrets.TASK_KEY }}" \ --max-time 10 || echo "Cache clear timed out (non-fatal)" # .github/workflows/deploy.yml name: Test and Deploy on: push: branches: [main] workflow_dispatch: inputs: site: description: 'Deploy specific site (or all)' required: false default: 'all' type: choice options: [all, dwv, tvh, vvv, tvs] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: '8.3' extensions: sqlite3, pdo_sqlite, -weight: 500;">curl - name: Lint PHP files run: | EXIT_CODE=0 while IFS= read -r file; do php -l "$file" > /dev/null 2>&1 || EXIT_CODE=1 done < <(find app/ public/ cron/ -name '*.php') exit $EXIT_CODE - name: Run test suite 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 steps: - uses: actions/checkout@v4 - name: Deploy via FTP 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 **/.-weight: 500;">git/** **/tests/** **/backlink/** - name: Clear LiteSpeed cache run: | -weight: 500;">curl -sf "${{ secrets[format('{0}_URL', matrix.site)] }}/task/clear-cache?key=${{ secrets.TASK_KEY }}" \ --max-time 10 || echo "Cache clear timed out (non-fatal)" strategy: matrix: site: [dwv, tvh, vvv, tvs] fail-fast: false strategy: matrix: site: [dwv, tvh, vvv, tvs] fail-fast: false strategy: matrix: site: [dwv, tvh, vvv, tvs] fail-fast: false DWV_FTP_HOST=ftp.dailywatch.video [email protected] DWV_FTP_PASS=<password> VVV_FTP_HOST=ftp.viralvidvault.com [email protected] VVV_FTP_PASS=<password> # ... same pattern for TVH, TVS DWV_FTP_HOST=ftp.dailywatch.video [email protected] DWV_FTP_PASS=<password> VVV_FTP_HOST=ftp.viralvidvault.com [email protected] VVV_FTP_PASS=<password> # ... same pattern for TVH, TVS DWV_FTP_HOST=ftp.dailywatch.video [email protected] DWV_FTP_PASS=<password> VVV_FTP_HOST=ftp.viralvidvault.com [email protected] VVV_FTP_PASS=<password> # ... same pattern for TVH, TVS server: ${{ secrets[format('{0}_FTP_HOST', matrix.site)] }} # When matrix.site = 'vvv', resolves to secrets.VVV_FTP_HOST server: ${{ secrets[format('{0}_FTP_HOST', matrix.site)] }} # When matrix.site = 'vvv', resolves to secrets.VVV_FTP_HOST server: ${{ secrets[format('{0}_FTP_HOST', matrix.site)] }} # When matrix.site = 'vvv', resolves to secrets.VVV_FTP_HOST - name: Verify deployment run: | URL="${{ secrets[format('{0}_URL', matrix.site)] }}" echo "Checking $URL..." # Basic availability check HTTP_CODE=$(-weight: 500;">curl -s -o /dev/null -w '%{http_code}' "$URL" --max-time 15) if [ "$HTTP_CODE" != "200" ]; then echo "::error::${{ matrix.site }} returned HTTP $HTTP_CODE" exit 1 fi # Version check via health endpoint DEPLOYED=$(-weight: 500;">curl -sf "$URL/health" --max-time 10 | python3 -c "import sys,json; print(json.load(sys.stdin).get('commit','unknown'))") echo "${{ matrix.site }}: HTTP $HTTP_CODE, version $DEPLOYED" - name: Verify deployment run: | URL="${{ secrets[format('{0}_URL', matrix.site)] }}" echo "Checking $URL..." # Basic availability check HTTP_CODE=$(-weight: 500;">curl -s -o /dev/null -w '%{http_code}' "$URL" --max-time 15) if [ "$HTTP_CODE" != "200" ]; then echo "::error::${{ matrix.site }} returned HTTP $HTTP_CODE" exit 1 fi # Version check via health endpoint DEPLOYED=$(-weight: 500;">curl -sf "$URL/health" --max-time 10 | python3 -c "import sys,json; print(json.load(sys.stdin).get('commit','unknown'))") echo "${{ matrix.site }}: HTTP $HTTP_CODE, version $DEPLOYED" - name: Verify deployment run: | URL="${{ secrets[format('{0}_URL', matrix.site)] }}" echo "Checking $URL..." # Basic availability check HTTP_CODE=$(-weight: 500;">curl -s -o /dev/null -w '%{http_code}' "$URL" --max-time 15) if [ "$HTTP_CODE" != "200" ]; then echo "::error::${{ matrix.site }} returned HTTP $HTTP_CODE" exit 1 fi # Version check via health endpoint DEPLOYED=$(-weight: 500;">curl -sf "$URL/health" --max-time 10 | python3 -c "import sys,json; print(json.load(sys.stdin).get('commit','unknown'))") echo "${{ matrix.site }}: HTTP $HTTP_CODE, version $DEPLOYED" notify: needs: deploy if: failure() runs-on: ubuntu-latest steps: - name: Alert on failure run: | -weight: 500;">curl -X POST "${{ secrets.DISCORD_WEBHOOK }}" \ -H 'Content-Type: application/json' \ -d '{"content": "Deploy FAILED for ${{ github.sha }}. Check: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}' notify: needs: deploy if: failure() runs-on: ubuntu-latest steps: - name: Alert on failure run: | -weight: 500;">curl -X POST "${{ secrets.DISCORD_WEBHOOK }}" \ -H 'Content-Type: application/json' \ -d '{"content": "Deploy FAILED for ${{ github.sha }}. Check: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}' notify: needs: deploy if: failure() runs-on: ubuntu-latest steps: - name: Alert on failure run: | -weight: 500;">curl -X POST "${{ secrets.DISCORD_WEBHOOK }}" \ -H 'Content-Type: application/json' \ -d '{"content": "Deploy FAILED for ${{ github.sha }}. Check: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}' - Run PHP linting and tests - Deploy to multiple hosts in parallel - Exclude sensitive files (.env, data/) - Clear LiteSpeed cache after deploy - Verify each deployment succeeded - Deploy time went from 15 minutes (manual, sequential) to 4 minutes (automated, parallel) - Zero missed deployments (no more "forgot to deploy to tvs") - Three bad deploys caught by the verification step before any user noticed