Skip to main content

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

OptionVerdictWhy
GitHub Actions git-delta deployChosenCI and CD in one place, no extra account
easingthemes/ssh-deployUsed, then replacedFine for a full rsync, but see the plugin trap below
appleboy/ssh-action (git pull on the server)NoMeans the server needs git and repo access
Deployer (atomic releases + symlink)NoRelease-folder sprawl, and the docroot would need repointing at current/public
Buddy, Deploybot, EnvoyerNoSwapping one paid third party for another
Panels (Ploi, Forge, RunCloud, GridPane)NoTies 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
SettingReason
--omit-dir-times --no-permsServer ownership and permissions differ from the runner's
No --deleteWith 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.

StepNote
actions/checkout@v4fetch-depth: 0, the delta diff needs full history
shivammathur/setup-php@v2PHP 8.3, composer
actions/setup-node@v4Node 22, cache: npm, with cache-dependency-path at the theme's lockfile
Buildcomposer install --no-dev --optimize-autoloader, npm ci, npm run build
Build stampWrites build.json with sha, short sha, timestamp, ref
Deploybash .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

SymptomCause
Merge to main silently did nothingActions was disabled on the repo. Check "enabled": false before debugging anything else
[INPUTS] remoteUser is mandatorySecret named REMOTE_USER instead of the expected name
Node 20 deprecation warningsCosmetic

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 -l and node --check scoped to project files rather than vendored ones, plus branch protection. PHPCS deliberately left out for now.