Client Area

Deploying to cPanel from GitHub Actions: Automated CI/CD for Shared Hosting

ByDomain India Team
10 min read22 Apr 20262 views

In this article

  • 1What this gets you
  • 2Prerequisites
  • 3Choose your deployment mode
  • 4One-time setup: SSH deploy key
  • 5Generate the key pair on your laptop

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:

  1. GitHub Actions checks out the code
  2. Installs PHP and Node dependencies
  3. Runs your tests
  4. Builds frontend assets
  5. rsyncs the files over SSH to your cPanel account
  6. 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:

ModeWhen to useProsCons
SFTP uploadNo SSH on your planWorks anywhereSlow, no post-deploy commands
SSH + rsyncSSH availableFast, incremental, runs commands after deployRequires SSH keys
SSH + git pullSSH + git on serverCleanest; full repo on serverRepo 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

bash
ssh-keygen -t ed25519 -f ~/.ssh/github-deploy -C "[email protected]"
# press Enter at the passphrase prompt — CI cannot type

You 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

  1. cPanel → SSH Access → Manage SSH Keys → Import Key
  2. Paste the content of ~/.ssh/github-deploy.pub into the "Public Key" field
  3. Give it a name like github-actions-deploy-key
  4. Click Import
  5. In the key list, click "Manage" → "Authorize"

Verify it works from your laptop:

bash
ssh -i ~/.ssh/github-deploy [email protected]
# should log you in without prompting for a password

If this fails, fix it now — don't proceed until manual SSH works.

Add the private key to GitHub Secrets

  1. On GitHub, open your repo → Settings → Secrets and variables → Actions
  2. Click "New repository secret" and add the following (one by one):
NameValue
CPANEL_HOSTyourdomain.com or server.yourhost.com
CPANEL_USERyour cPanel username
CPANEL_PORT22 (or 2222 if your host uses that)
CPANEL_SSH_KEYentire 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:

yaml
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:restart

Walking 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 deploy job (which needs: 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:

  1. GitHub Secrets — only the things CI needs during deploy (SSH key, hosts, usernames). Access via ${{ secrets.NAME }} in the workflow.
  2. Production `.env` on the server — runtime secrets (DB password, Stripe keys, JWT secret). Never committed; never transmitted via CI.
  3. 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.

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-00

Deploy flow becomes:

  1. rsync to releases/<new-timestamp>/
  2. Symlink shared folders (.env, storage/, uploads/) into the new release
  3. Run migrations, build caches
  4. Atomically flip: ln -sfn releases/<new> current
  5. 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=1G or eslint .
  • Security scan: composer audit (PHP) or npm audit --audit-level=high (Node.js)
  • Linter: php-cs-fixer or prettier --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:

yaml
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:

yaml
- 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:

yaml
- name: Smoke test
  run: |
    sleep 30
    curl -sf https://yourdomain.com/health || exit 1

If 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:

yaml
env:
  COMPOSER_MEMORY_LIMIT: -1

Permissions wrong after rsync

Files rsynced have your CI user's default permissions. Add a post-sync step:

yaml
- 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.

Was this article helpful?

Your feedback helps us improve our documentation

Still need help? Submit a support ticket