Deploying to cPanel from GitHub Actions: Automated CI/CD for Shared Hosting
Moving from "upload via FTP when I remember" to "every git push deploys automatically" is the single biggest productivity upgrade for a one-developer shop. This guide sets up a complete GitHub Actions pipeline that builds, tests, and deploys a PHP or Node.js application to cPanel hosting — no paid CI platforms, no Docker required.
What this gets you
When you git push to main:
- GitHub Actions checks out the code
- Installs PHP and Node dependencies
- Runs your tests
- Builds frontend assets
- rsyncs the files over SSH to your cPanel account
- Runs post-deploy database migrations
If tests fail, the deployment is blocked. If it succeeds, your site is live in 90 seconds.
Prerequisites
- Any of our cPanel or DirectAdmin plans with SSH access (Pro tier and above)
- A GitHub repository (public or private — both work)
- Your app runnable with a known build command (
composer install/npm ci && npm run build) - A MySQL database + user already set up in cPanel
Choose your deployment mode
Three modes, pick based on your plan:
| Mode | When to use | Pros | Cons |
|---|---|---|---|
| SFTP upload | No SSH on your plan | Works anywhere | Slow, no post-deploy commands |
| SSH + rsync | SSH available | Fast, incremental, runs commands after deploy | Requires SSH keys |
| SSH + git pull | SSH + git on server | Cleanest; full repo on server | Repo visible if not secured |
This guide covers SSH + rsync — the best balance for most setups.
One-time setup: SSH deploy key
Generate the key pair on your laptop
ssh-keygen -t ed25519 -f ~/.ssh/github-deploy -C "[email protected]"
# press Enter at the passphrase prompt — CI cannot typeYou now have two files:
~/.ssh/github-deploy(private — NEVER commit this, NEVER share)~/.ssh/github-deploy.pub(public — safe to share)
Authorize the public key on cPanel
- cPanel → SSH Access → Manage SSH Keys → Import Key
- Paste the content of
~/.ssh/github-deploy.pubinto the "Public Key" field - Give it a name like
github-actions-deploy-key - Click Import
- In the key list, click "Manage" → "Authorize"
Verify it works from your laptop:
ssh -i ~/.ssh/github-deploy [email protected]
# should log you in without prompting for a passwordIf this fails, fix it now — don't proceed until manual SSH works.
Add the private key to GitHub Secrets
- On GitHub, open your repo → Settings → Secrets and variables → Actions
- Click "New repository secret" and add the following (one by one):
| Name | Value |
|---|---|
CPANEL_HOST | yourdomain.com or server.yourhost.com |
CPANEL_USER | your cPanel username |
CPANEL_PORT | 22 (or 2222 if your host uses that) |
CPANEL_SSH_KEY | entire content of ~/.ssh/github-deploy (private key, including -----BEGIN ...----- lines) |
These secrets are encrypted at rest and masked in logs.
The workflow file
Create .github/workflows/deploy.yml in your repo:
name: Deploy to cPanel
on:
push:
branches: [main]
workflow_dispatch: # allows manual trigger from GitHub UI
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: mbstring, xml, bcmath, intl, zip, pdo_mysql
coverage: none
- name: Cache Composer packages
uses: actions/cache@v4
with:
path: ~/.composer/cache
key: composer-${{ hashFiles('composer.lock') }}
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run tests
run: vendor/bin/phpunit
deploy:
needs: test # only runs if tests pass
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: mbstring, xml, bcmath, intl, zip, pdo_mysql
- name: Install production dependencies
run: composer install --no-dev --optimize-autoloader --no-interaction
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- name: Build frontend
run: |
npm ci
npm run build
- name: Laravel optimisations
run: |
php artisan config:cache
php artisan route:cache
php artisan view:cache
- name: Deploy via rsync over SSH
uses: burnett01/[email protected]
with:
switches: -avzr --delete --exclude-from=.rsyncignore
path: ./
remote_path: /home/${{ secrets.CPANEL_USER }}/laravel-app/
remote_host: ${{ secrets.CPANEL_HOST }}
remote_port: ${{ secrets.CPANEL_PORT }}
remote_user: ${{ secrets.CPANEL_USER }}
remote_key: ${{ secrets.CPANEL_SSH_KEY }}
- name: Run post-deploy commands on server
uses: appleboy/[email protected]
with:
host: ${{ secrets.CPANEL_HOST }}
port: ${{ secrets.CPANEL_PORT }}
username: ${{ secrets.CPANEL_USER }}
key: ${{ secrets.CPANEL_SSH_KEY }}
script: |
cd ~/laravel-app
php artisan migrate --force
php artisan queue:restartWalking through what each step does
- `on: push: branches: [main]` — workflow triggers when code is pushed to main
- `test` job — runs in a fresh Ubuntu container, installs PHP 8.2 + Composer + dependencies, runs your test suite. If tests fail, the
deployjob (whichneeds: test) is skipped - `deploy` job — rebuilds production artifacts, then rsyncs them to your server
- Post-deploy SSH commands — runs migrations, clears queue workers
The .rsyncignore file
At your project root, create .rsyncignore:
.git/
.github/
node_modules/
tests/
.env
.env.example
.editorconfig
.gitignore
README.md
docker-compose.yml
storage/logs/*
bootstrap/cache/*Critical: .env is excluded. Your production .env lives on the server only — never pushed from CI.
Secrets management in this pipeline
Three places your secrets live:
- GitHub Secrets — only the things CI needs during deploy (SSH key, hosts, usernames). Access via
${{ secrets.NAME }}in the workflow. - Production `.env` on the server — runtime secrets (DB password, Stripe keys, JWT secret). Never committed; never transmitted via CI.
- Your laptop's `.env` — development secrets, different from production.
The rule: CI knows how to reach the server; the server knows its own secrets. CI never touches runtime secrets.
See our Environment Variables & Secrets Management guide for the broader pattern.
Zero-downtime deployment (atomic symlink)
Standard rsync overwrites files in-place. There is a brief window (usually under 1 second) where the site sees a mix of old and new files. For most shared-hosting sites this is acceptable.
If you want true zero-downtime deploys, use the atomic symlink pattern:
/home/user/app/
├── releases/
│ ├── 2026-04-22-14-30-00/
│ ├── 2026-04-22-16-00-00/ ← just deployed
│ └── 2026-04-22-18-45-00/ ← next deploy lands here
├── shared/
│ ├── .env
│ ├── storage/
│ └── uploads/
└── current -> releases/2026-04-22-16-00-00Deploy flow becomes:
- rsync to
releases/<new-timestamp>/ - Symlink shared folders (
.env,storage/,uploads/) into the new release - Run migrations, build caches
- Atomically flip:
ln -sfn releases/<new> current - Reload PHP-FPM / webserver (on LiteSpeed, automatic)
Rollback is ln -sfn releases/<previous> current.
Keep the last 5 releases, delete older ones in the workflow.
Running tests that matter
The test job above runs PHPUnit. For Laravel, use php artisan test --parallel. For Node.js, npm test or jest --ci.
Important: tests must pass with your production environment assumptions (real database, production config). Using an in-memory SQLite for tests while production is MySQL can hide bugs.
For critical CI, add:
- Static analysis:
phpstan analyse --memory-limit=1Goreslint . - Security scan:
composer audit(PHP) ornpm audit --audit-level=high(Node.js) - Linter:
php-cs-fixerorprettier --check
Each adds ~30 seconds to the CI run and catches a different class of bug.
Multi-environment deployments
If you have staging in addition to production:
jobs:
deploy:
strategy:
matrix:
include:
- branch: main
remote_path: /home/user/prod-app/
deploy_host: prod.example.com
- branch: develop
remote_path: /home/user/staging-app/
deploy_host: staging.example.com
if: github.ref == format('refs/heads/{0}', matrix.branch)Or create two separate workflow files: deploy-prod.yml (triggers on main) and deploy-staging.yml (triggers on develop).
Staging should be a separate cPanel account or subdomain with its own database. The test data should mirror production schema + realistic volume so you catch production-scale bugs before they ship.
Monitoring and alerting
Slack / Discord notification on failed deploy
Add at the end of your workflow:
- name: Notify on failure
if: failure()
uses: rtCamp/action-slack-notify@v2
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
SLACK_TITLE: 'Deploy Failed'
SLACK_MESSAGE: 'Deploy to cPanel failed — check the workflow logs.'Post-deploy smoke test
Run a simple curl to verify the site still responds:
- name: Smoke test
run: |
sleep 30
curl -sf https://yourdomain.com/health || exit 1If the endpoint returns non-2xx, the workflow fails (red X on GitHub), alerting you immediately.
Troubleshooting
"Permission denied (publickey)"
The SSH key in your GitHub Secret does not match an authorized key on cPanel. Re-check:
- You imported AND authorized the key in cPanel → SSH Access → Manage SSH Keys
- You pasted the ENTIRE private key into GitHub Secrets including the BEGIN and END lines
- The private key file has no extra whitespace or line-wrapping issues
"No such file or directory" on rsync destination
The remote_path doesn't exist. Rsync won't create the parent directory for you. SSH in once manually and mkdir -p ~/laravel-app.
"Composer memory exceeded" on the CI runner
Add this env var to the install step:
env:
COMPOSER_MEMORY_LIMIT: -1Permissions wrong after rsync
Files rsynced have your CI user's default permissions. Add a post-sync step:
- name: Fix permissions
uses: appleboy/[email protected]
with:
host: ${{ secrets.CPANEL_HOST }}
username: ${{ secrets.CPANEL_USER }}
key: ${{ secrets.CPANEL_SSH_KEY }}
script: |
cd ~/laravel-app
find . -type d -exec chmod 755 {} \;
find . -type f -exec chmod 644 {} \;
chmod -R 775 storage bootstrap/cache.htaccess not picked up after deploy
Check that your .rsyncignore does not exclude .htaccess (it uses a leading dot, which .* patterns sometimes match accidentally). Also confirm AllowOverride All is set on your account — it is by default on our plans.
Workflow runs fine but nothing deploys
Check on: trigger. If you set branches: [master] but push to main, nothing fires. Also check the Actions tab — a failed auth at the rsync step is often silent to the push user.
Deploy is too slow
The first deploy copies everything — several GB for typical projects, can take 10+ minutes. Subsequent deploys are incremental (rsync only transfers changed files) and should be under 2 minutes.
Speed tips: add --delete-excluded to rsync to clean up old files; exclude .cache directories; use GitHub Actions cache for node_modules and vendor.
Frequently asked questions
Can I deploy to cPanel without SSH access?
Yes — use the SFTP-upload pattern with SamKirkland/FTP-Deploy-Action. Slower, no post-deploy commands, but works on any cPanel plan. Your .env and database migrations must be handled manually.
Does this work for Laravel, WordPress, and static sites equally?
Laravel: yes, as shown. WordPress: yes, but you usually don't need to deploy via CI (WordPress is primarily database-driven; use All-in-One Migration plugin instead). Static sites: even simpler — skip the PHP setup, run npm run build, rsync the dist/ or public/ folder.
How do I run database migrations safely in CI/CD?
php artisan migrate --force (required in production mode). Test migrations in staging first. Never auto-rollback on error — leave the partial-migration state for manual review.
Is FTP-based deployment still OK?
For low-stakes hobby projects, yes. For anything with users: no. FTP is plaintext (or FTPS/SFTP only if you configure it), slow, and cannot run post-deploy commands. SSH + rsync is the 2026 default.
Can I use GitLab CI or Bitbucket Pipelines with this same pattern?
Yes. The concepts are identical; the YAML syntax differs. rsync + SSH works on any CI platform.
What about preview deployments for pull requests?
Advanced topic. Create a workflow that triggers on pull_request: opened. Deploy each PR to a subdomain like pr-123.preview.yourdomain.com. Delete on PR close. Pattern is doable but non-trivial — start with main-branch CI first, add preview deploys later.
How do I roll back a bad deployment?
With the atomic symlink pattern: ln -sfn releases/<previous> current. Without it: run the workflow against the previous commit — git revert + push, or workflow_dispatch on an older tag.
Need help setting up CI/CD for your specific stack? [email protected] — we help VPS and cPanel Pro customers set up GitHub Actions workflows as part of standard support.