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/**
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
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
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
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"
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 }}\"}"
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
\rin FTP path configs corrupts remote paths silently. -
Exclude patterns are critical — Never deploy
.envfiles ordata/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)