Auto-deploying a WordPress theme from GitHub to cPanel
Shared cPanel hosting rarely offers a deploy button, and FTP uploads are where mistakes happen: a forgotten file, an old version overwritten, a syntax error that takes the site down. This site deploys itself instead, with GitHub Actions. I push to main, and about a minute later the change is live, but only if the checks pass.
The shape of it
git push ─▶ GitHub Actions: check ─▶ deploy ─▶ smoke test
│ fail: nothing deploys
▼
signed POST to the server ─▶ git fetch ─▶ rsync chosen folders
The repo holds the whole wp-content code, not WordPress core, wp-config.php or uploads. A file called deploy.paths lists the folders that go live, such as the theme.
1. Checks first
The workflow lints the code that’s about to be deployed, on the same PHP version as the server:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- name: PHP syntax
run: find wp-content/themes/my-theme -name '*.php' -print0 | xargs -0 -n1 php -l
A missing semicolon now fails in GitHub, not on the live site.
2. A signed request instead of SSH
Many shared hosts limit SSH from outside, so the deploy job doesn’t log in to the server. It sends a small JSON payload to a PHP endpoint, signed with a secret both sides know:
body='{"ref":"refs/heads/main","sha":"'$GITHUB_SHA'","timestamp":'$(date +%s)'}'
sig="sha256=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$DEPLOY_SECRET" | sed 's/^.* //')"
curl -fsS -X POST -H "X-Hub-Signature-256: $sig" --data "$body" "$DEPLOY_URL"
The endpoint checks the signature with hash_equals(), rejects requests older than five minutes, and answers anything unsigned with a plain 404, so it’s invisible to scanners. The secret lives in GitHub’s encrypted secrets and in a file outside the web root with chmod 600.
3. Pull outside the web root, sync in
On the server, a full clone of the repo sits outside the public folder. The endpoint updates it and copies only the listed folders into the live site:
exec( 'git -C ~/repos/site fetch -q origin main && git -C ~/repos/site reset -q --hard origin/main' );
exec( 'rsync -rlt --delete --exclude=.git ~/repos/site/wp-content/themes/my-theme/ ~/public_html/wp-content/themes/my-theme/' );
Folder names are checked against a whitelist (wp-content/themes/*, plugins/*, mu-plugins/*), so a typo in deploy.paths can never overwrite core or uploads. The server pulls with a read-only deploy key: it can fetch the repo but never push to it.
4. Smoke test
After deploying, the workflow requests a few pages and fails if any returns an error or contains “Fatal error”. A red cross in GitHub is a much better way to learn about a problem than an email from a visitor.
What I’d add for a team
- Run the checks on pull requests too, so a branch shows whether it’s safe before merging.
- Add PHP_CodeSniffer with the WordPress standards.
- Bump the theme version on each release, so cached CSS and JS refresh for everyone.
The habits that make this calm are the same ones in Git branching for agency projects: small commits, one change at a time.