WordPress CI/CD: GitHub Actions for Automated Deployment

A practical CI/CD pipeline for WordPress with GitHub Actions, covering PHPCS linting, PHPUnit tests, SSH-based deployment, WP-CLI remote commands, staging to production promotion, and secrets management.

The 11 p.m. WordPress deploy ritual has to end

Most WordPress deployments in 2026 still look the same way they did ten years ago. Someone on the team opens FileZilla, connects to the production server, drags a folder over, watches the progress bar, refreshes the site, and hopes nothing exploded. The deploy usually happens at 11 p.m. because that is when traffic is lowest and rollback is easiest if something breaks. If you have been in the WordPress world for a while you have lived through this exact ritual, and you know how it ends: at some point, someone drags the wrong folder, or forgets to clear a cache, or overwrites a live file a client just edited. I have done this myself, and I have watched clients lose a morning of work to it.

You can do better, and the setup is surprisingly quick. A working continuous integration and continuous deployment pipeline for WordPress takes an afternoon to build in GitHub Actions and pays itself back the first time you avoid a midnight rollback. This guide walks through a pipeline I actually run in production on client sites, covering linting against WordPress Coding Standards, PHPUnit on pull requests, automatic staging deploys, and a gated production deploy that waits for a human approval click.

No paid CI service, no third-party runners, no Jenkins instance rotting in a corner of the office network. Just GitHub Actions, SSH, and WP-CLI doing the work that was always going to have to happen anyway.

What the pipeline actually does

At a high level the workflow does five things, in order, on every push to the repository:

  1. Lint PHP against WordPress Coding Standards (WPCS) with PHPCS.
  2. Run PHPUnit against a matrix of PHP 8.1, 8.2, and 8.3, on WordPress latest and 6.7.
  3. Build assets with npm ci and npm run build if the repository ships Gutenberg blocks or theme CSS.
  4. Deploy over SSH using rsync to staging on every push to the develop branch.
  5. Deploy to production only after a manual approval in a protected GitHub Environment, triggered by a push to main.

A single .github/workflows/deploy.yml file handles all of it. The key insight that makes this so much simpler than it used to be: GitHub Environments give you per-environment secrets, required reviewers, and deployment history, all for free on public repositories and as part of the GitHub Team plan on private ones. That eliminates the main reason teams used to reach for Jenkins in the first place, which was the need for an approval gate and per-environment credentials.

The complete workflow file

Here is the actual .github/workflows/deploy.yml I run on real client sites. Copy it into your repository, adjust the branch names and secret references, and you have a working pipeline.

A few things worth noticing about the structure. The concurrency block at the top prevents two deploys racing each other when someone pushes twice in quick succession. The cancel-in-progress: false setting means the second push waits for the first to finish rather than killing it midway, which is what you want for deploy jobs because canceling a partial rsync leaves the server in a weird state.

The PHP matrix in the test job runs the same tests against three PHP versions and two WordPress versions, so you catch the bugs that only show up on specific combinations. I have shipped PHP 8.1 only to find a client still running PHP 7.4 on shared hosting, and the matrix approach caught the issue before it reached their site.

The .deployignore file

Never rsync your repository wholesale. A lean .deployignore prevents development tooling from leaking onto the server, which is both a security concern (exposing composer.json to the internet is a footprint attackers actively scan for) and a performance concern (shipping node_modules to production bloats the deploy by hundreds of megabytes).

Anything you need at runtime (for example, vendor/ if you are using Composer inside the theme or plugin) must not be listed here, and it must be built in CI before the rsync step runs. Do not build vendor/ on the production server. It is slow, it depends on the server having the right Composer version, and it fails silently when a package has a post-install script that needs an environment variable you did not set.

Secrets, environments, and approval gates

The security model is where most CI/CD guides go wrong. Put SSH keys and server paths in GitHub Environment secrets, not repository-level secrets. That way a push to develop can only read the staging bucket, and a push to main can only read production. Repository-level secrets are visible to every workflow, which is exactly what you do not want for production credentials.

In the production environment settings, turn on the following three protections:

  • Required reviewers: add yourself and one other maintainer. Every production deploy now waits for a human click, which has saved me from at least three accidental pushes to main over the last year.
  • Wait timer: 5 minutes. This is a cheap sanity check. It forces you to read the deploy diff one more time before it goes live, which is exactly long enough to catch the occasional question of why this is touching the plugins directory.
  • Deployment branches: restrict to main only. This prevents an experimental feature branch from accidentally triggering a production run.

Generate the SSH key with a dedicated identity like ssh-keygen -t ed25519 -C gha-deploy. Copy the public key into the server’s authorized_keys file under a distinct user account (not root, ideally a deploy-only user with write access only to the WordPress directory). Paste the private key into the SSH_KEY environment secret in GitHub. For belt-and-suspenders security, lock the server-side key down by prefixing the authorized_keys entry with the GitHub Actions IP range, which is published at the GitHub meta API. Now even if the private key leaks, it only works from GitHub’s runners.

WP-CLI over SSH: the part most guides skip

Most CI/CD walkthroughs for WordPress stop at rsync, which leaves your database schema and caches stale after every deploy. This is the single biggest reason WordPress CI/CD gets abandoned: the first time a plugin update requires a database migration and the deploy does not run it, the site breaks and the team goes back to manual deploys in frustration.

Four WP-CLI commands cover the common cases after any deploy:

Command

When to run

wp core update-db

After a core or plugin version bump that ships a schema change

wp cache flush

Always after deploy if using object cache (Redis or Memcached)

wp rewrite flush

After changing custom post types, taxonomies, or permalink structures

wp transient delete --all

Nuclear option for stale cached queries, skip unless actively debugging

Run these inside the appleboy/ssh-action step shown in the workflow file above. One gotcha worth noting: wp rewrite flush requires the request context to know your permalink structure, so if you are on a WordPress multisite network, run wp rewrite flush --network instead. I learned that one the hard way after a deploy left half the network returning 404 on permalinks for twenty minutes.

PHPCS in CI, done correctly

Run PHPCS against WordPress Coding Standards only on changed files, not the whole repository. A full-repo scan on a mature plugin takes several minutes and produces a wall of noise from legacy code nobody will fix today. That noise trains your team to ignore PHPCS output, which is the opposite of what you want.

The right setup: install dealerdirect/phpcodesniffer-composer-installer and wp-coding-standards/wpcs in composer.json, plus a .phpcs.xml.dist that sets the text domain and a reasonable severity threshold (I usually set severity to 5 to filter out the minor stuff). The cs2pr tool converts PHPCS output into GitHub check annotations so errors appear inline on the PR diff, which is the only way anyone actually reads them.

For more aggressive static analysis, pair PHPCS with PHPStan at level 5 or 6. PHPStan catches a different class of bug (type errors, undefined method calls on dynamic objects) and complements WPCS well. It is worth reading my AI code review for WordPress guide for the complete static analysis stack I use.

Rollback strategy: under two minutes

A rollback is git revert, followed by letting the pipeline deploy the revert commit. This is why --delete in rsync is safe: it mirrors the repository state, so reverting a commit reverts the server. Keep database migrations reversible (if you ship a schema change, write the down migration alongside it) and you can roll back in under two minutes. In my experience, the rollbacks that hurt are the ones where the database went one way and the code went back the other.

For a deeper look at deployment-adjacent topics including containerized WordPress dev environments, see my guide on the WordPress 7.0 Connectors API which covers the newer deployment patterns core is moving toward.

The cost math: GitHub Actions minutes

GitHub Actions gives you 2,000 free minutes per month on private repositories with the GitHub Free plan, 3,000 on GitHub Pro, and unlimited minutes on public repositories. A typical plugin or theme deploy job uses between 2 and 5 minutes per run (lint 30 seconds, tests 90 seconds across the matrix, build 45 seconds, rsync 15 seconds). Even at 10 deploys a day, you are looking at roughly 30 minutes of usage per day, or under 1,000 minutes a month. The free tier handles it comfortably.

If you are running the workflow on 20 client sites from a single GitHub account, the math gets tighter. At that point either move the sites to a GitHub Teams plan for the extra minutes, split repositories across accounts, or cache dependencies aggressively with actions/cache to cut per-run minutes in half.

What this pipeline does not cover

This is the 80 percent path. It does not handle blue-green deployments, feature flags, database migrations with per-row data transforms, or multi-region failover. If you are running a single VPS or a managed WordPress host with SSH access, you do not need any of that, and adding it prematurely is the main reason teams abandon their CI/CD setup six months in.

The things that would justify the additional complexity: multi-million visitor traffic, strict uptime SLAs, regulated industries with change-management requirements, or a team of five-plus developers deploying multiple times a day. If that is you, the pipeline above is still the right starting point, but add feature flag gates (via a plugin like Flagsmith or LaunchDarkly) and database migration staging separately from code deploys.

Shipping this yourself: the three-week plan

The biggest mistake teams make when adopting this pipeline is trying to enable all five jobs at once. That is how you get a broken CI configuration nobody wants to debug on day one. Do it incrementally:

Week 1. Start with just the lint job. Get PHPCS running on every push. Fix the errors it reports. Do nothing else.

Week 2. Add the test job with the matrix. Fix whatever breaks. Do not touch production.

Week 3. Wire up staging deploys. Push to develop, watch it land on staging. Repeat until you trust it.

Week 4 and beyond. Wire up production only after staging has been boring for a full week. Boring is the goal. If staging is exciting, production is not ready.

The full pipeline should take about an hour a week for three to four weeks, and once it is in place, every deploy becomes a single git push instead of a 30-minute ritual. The time saved in the first month alone pays for the setup. The stress saved over six months is the real win.

Varun Dubey

Written by

Varun Dubey

Varun Dubey runs Wbcom Designs, the WordPress studio he founded in India in 2009. He has spent sixteen years building on WordPress and BuddyPress, shipping client work and products such as Reign, BuddyX, Jetonomy and MediaVerse, and has been putting Claude and OpenAI workflows into production since 2023. He writes up what the studio learns along the way.

More about Varun

No comments yet