Deploying WordPress with GitHub Actions
Pattern for replacing a hosted deploy service with GitHub Actions, worked out while moving off DeployHQ. Reusable on any WordPress site.
The decision
| Option | Verdict | Why |
|---|---|---|
| GitHub Actions git-delta deploy | Chosen | CI and CD in one place, no extra account |
easingthemes/ssh-deploy | Used, then replaced | Fine for a full rsync, but see the plugin trap below |
appleboy/ssh-action (git pull on the server) | No | Means the server needs git and repo access |
| Deployer (atomic releases + symlink) | No | Release-folder sprawl, and the docroot would need repointing at current/public |
| Buddy, Deploybot, Envoyer | No | Swapping one paid third party for another |
| Panels (Ploi, Forge, RunCloud, GridPane) | No | Ties deployment to the hosting panel. Around $10-15/mo |
Motive was consolidation onto GitHub and dropping a paid third party, not a specific bill. It's reversible, so it was never urgent.
The trap that shapes everything
A plain full rsync overwrites plugins the server auto-updated, replacing them with older copies from the repo. That's a security regression, and it's the whole reason this ends up as a hand-written delta script rather than a stock action.
Three fixes were weighed: exclude third-party plugins, gitignore them, or do a true git-delta. Delta won, because a plugin change you do deliberately commit still deploys.
Consequence worth stating plainly: git is not a record of production plugin versions. Only commit a third-party plugin change if it's at or ahead of the server's version.
How the delta works
A marker file ~/.smpw_deployed_sha, in the deploy user's home and outside the web root, records the last shipped commit. Then:
git diff --name-only --diff-filter=ACMRT <last> <new> -- public/ | sed 's#^public/##'
That list is rsynced with --files-from. Deletions are handled separately with --diff-filter=D then rm -f over SSH.
No marker, or a marker git doesn't recognise, triggers a one-off full baseline sync. It self-heals.
Compiled assets are gitignored, so they're invisible to the diff and have to be sent every time regardless: style.css, style-editor.css, js/script.min.js, build.json.
rsync settings
rsync -rlvz --omit-dir-times --no-perms
| Setting | Reason |
|---|---|
--omit-dir-times --no-perms | Server ownership and permissions differ from the runner's |
No --delete | With uploads excluded, --delete would wipe the uploads directory |
| Excludes | /wp-content/uploads/, wp-config.php, node_modules/, .git/, .github/, .ddev/ |
wp-config.php stays gitignored and per-environment. Nothing in the deploy touches it or uploads.
Workflow shape
Triggers on push to main plus workflow_dispatch, with concurrency: deploy-production, cancel-in-progress: true and permissions: contents: read.
| Step | Note |
|---|---|
actions/checkout@v4 | fetch-depth: 0, the delta diff needs full history |
shivammathur/setup-php@v2 | PHP 8.3, composer |
actions/setup-node@v4 | Node 22, cache: npm, with cache-dependency-path at the theme's lockfile |
| Build | composer install --no-dev --optimize-autoloader, npm ci, npm run build |
| Build stamp | Writes build.json with sha, short sha, timestamp, ref |
| Deploy | bash .github/scripts/deploy.sh |
The build stamp is worth copying. It becomes the Sentry release and the "what's actually live" figure in the admin, falling back to dev locally.
Secrets
PRODUCTION_SSH_KEY, PRODUCTION_SSH_HOST, PRODUCTION_SSH_USER, PRODUCTION_DEPLOY_PATH, plus optional PRODUCTION_SSH_PORT defaulting to 22.
ssh-keygen -t ed25519 -C "gh-actions-prod" -f ~/.ssh/prod_deploy -N ""
Public half goes on with ssh-copy-id. Set the Actions permissions allowlist rather than "allow all": GitHub-created actions, plus shivammathur/setup-php@* and easingthemes/ssh-deploy@*.
Migration approach
Run both systems in parallel off main, Actions to a new box, the old service to the existing live site. Nothing is at risk while both exist, and rollback is turning the new one off.
Sequencing snag that caught me out: gitignoring the compiled assets means untracking them, and the old deploy service reads that as a deletion and strips style.css from live, leaving the site unstyled. So the build step has to be working and verified on the old service first, then untrack. That kills any plan to split it into two tidy phases.
Rollback
git revert and push. The delta then ships the reverted files. No release-folder rollback, which matches what DeployHQ did anyway.
Database changes need no deploy step if migrations run through dbDelta() on an idempotent hook, with option-guarded one-shot seeds.
Failures hit along the way
| Symptom | Cause |
|---|---|
Merge to main silently did nothing | Actions was disabled on the repo. Check "enabled": false before debugging anything else |
[INPUTS] remoteUser is mandatory | Secret named REMOTE_USER instead of the expected name |
| Node 20 deprecation warnings | Cosmetic |
Verify a delta deploy is genuinely working by reading the log: a first run should say "No valid last-deployed SHA, full baseline sync" and write the marker. The next should say Delta deploy: <old> -> <new>, uploading N changed file(s).
Still unfinished on the original migration
- Cutover is open: confirm the new box actually renders (files landing and rsync exiting 0 is not the same thing), give it its own database, repoint DNS, retire the old service.
- No PR checks exist. The deploy runs on push to
main, so nothing validates code before production. Wanted:php -landnode --checkscoped to project files rather than vendored ones, plus branch protection. PHPCS deliberately left out for now.