Dragging themes over FTP in 2026 usually ends the same way: one file from inc/ is missing, composer.lock came from another laptop, and production is down because someone overwrote wp-config.php. Continuous Integration and Continuous Deployment are not enterprise theatre - they are how every Git change runs the same build, the same tests, and the same path onto the server.
This guide is for WordPress with a theme or plugin in the repository (Composer, npm, optionally Docker), not a site that only lives in the admin UI. If custom code still lives only in the database and media library, move themes and custom code into Git first - without that, CI/CD has nothing to build.
Runner docs: GitHub Actions. Keep WordPress server hardening in parallel: Hardening WordPress. For post-deploy commands, use WP-CLI.
Why you should not deploy WordPress via FTP
Three problems keep showing up in UK and US WooCommerce reviews and agency handovers:
- Environment drift - local PHP 8.2 and Composer 2.7, VPS on PHP 8.1 without
intl. Localcomposer install“works”; production dies on class not found. - No audit trail - after an incident you cannot say which commit was live. The host panel shows a file date, not a Git SHA.
- Secrets on disk - SFTP passwords saved in FileZilla, or a
.envcommitted “just for a minute”, remain a classic leak path.
CI/CD fixes this mechanically: build on a known image (shivammathur/setup-php or your own Docker), version the artifact by SHA, deploy over SSH with a key stored in Secrets.
WordPress GitHub Actions deploy workflow
A typical workflow for a custom theme plus Composer plugins:
- Trigger - push to
mainor merge a reviewed pull request.developoften deploys only to staging. - Build -
composer install --no-dev --optimize-autoloader,npm ci,npm run build. Pin PHP and Node in the workflow (php-version: '8.3',node-version: '24'). - Test - PHPUnit for plugins, optionally Playwright on the checkout path. Fail stops the job; no deploy.
- Artifact - pack a release without
.git, withoutnode_modules, withvendor/and builtassets/dist/. - Deploy - rsync or SCP into
releases/<run_id>/, then flip the symlink atomically.
Pin versions that still receive fixes. PHP 8.2 gets security fixes only until 31 December 2026, PHP 8.3 until 31 December 2027 (PHP supported versions). Node.js 20 reached end of life on 30 April 2026; Node.js 22 is supported until 30 April 2027 and Node.js 24 until 30 April 2028 (Node.js release schedule). Match the PHP minor version to production, not to the newest release.
Short GitHub Actions sketch (major tags for readability; the hardening section below explains why production workflows pin commit SHAs):
name: deploy-production
on:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
build-and-deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v7
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- run: composer install --no-dev --optimize-autoloader
- uses: actions/setup-node@v7
with:
node-version: '24'
cache: npm
- run: npm ci && npm run build
- name: rsync release
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
# write key, rsync to releases/$GITHUB_SHA, ln -sfnKeep the full symlink script in-repo as bin/release.sh and call it from the workflow. The same script still works from a laptop when Actions is down.
Stop two WordPress deploys from running at once
Two quick merges into main start two workflow runs. Without a guard, both rsync into releases/ and flip current in an order nobody chose. A concurrency group in GitHub Actions allows at most one running workflow per group; a newly queued run waits as pending (GitHub docs: control workflow concurrency).
Two details matter for deploys. By default a group holds one running and one pending run, and a newer run cancels the older pending one, so the latest commit wins and the one in the middle is skipped. cancel-in-progress: true would also cancel the running job, which can stop rsync halfway through a release directory. Keep it false for production and use true for test workflows on pull requests, where only the newest commit matters.
concurrency:
group: deploy-production
cancel-in-progress: falseBecause the symlink flips only after a complete rsync, a cancelled pending run never touches current.
Protect production with GitHub Actions environments
Add environment: production to the deploy job and configure that environment in the repository settings. Three protections apply (GitHub docs: deployments and environments):
- Required reviewers: up to six users or teams, and one approval releases the job. With self-review prevention on, the person who started the deploy cannot approve it.
- Deployment branches and tags: only
mainor release tags may deploy toproduction, so a workflow on a feature branch cannot reach the production host. - Environment secrets: available only to jobs that reference the environment, and with required reviewers set, not before approval. Keep the production
DEPLOY_SSH_KEYhere instead of in repository secrets.
Check the plan before relying on it. On GitHub Free, Pro and Team, required reviewers and wait timers work only in public repositories. Deployment branches and environment secrets also work in private repositories on Pro and Team, but not on Free. Most agency WordPress repositories are private, so on those plans the approval gate is not available and branch rules carry the protection.
Harden the GitHub Actions deploy workflow
The deploy job holds an SSH key to production, so the workflow file deserves the same review as PHP code. GitHub’s secure use reference lists the measures that matter here:
- Pin third-party actions to a full commit SHA. A tag such as
@v2can be moved or deleted. Repointing a SHA would need a SHA-1 collision. Take the SHA from the action’s own repository, not from a fork. GitHub now names a workflow dependency lockfile as the stronger option for repositories that adopt one. - Give
GITHUB_TOKENthe minimum. Setpermissions: contents: readat the top and raise it per job only where a step needs more. An rsync deploy over SSH needs nothing beyond reading the repository. - Keep untrusted input out of
run:. A pull request title or branch name inside${{ }}in a shell script is a script injection path. Pass it through an intermediate environment variable. - One secret per value. Structured data such as a JSON blob can break log redaction, so store host, user, path and key as separate secrets.
The same page notes that redaction covers only secrets used in a job and accessible to the runner, so never echo derived values such as a decoded key.
Zero-downtime WordPress deploys with symlinks
Overwriting files in place under public_html means some requests briefly see an old functions.php with a new template. On WooCommerce that can corrupt cart sessions during a Black Friday push.
Atomic layout:
- base:
/var/www/site/ - releases:
/var/www/site/releases/20260920-142211/ - shared: uploads,
wp-config.php, object cache - outside the release current: a symlink to the active release
After a successful rsync:
ln -sfn /var/www/site/releases/20260920-142211 /var/www/site/currentNginx root points at .../current/public (or your equivalent). The switch is immediate. Prune old releases after N days, or keep the last three for rollback.
WordPress uploads must live outside the release - otherwise every deploy deletes media or copies gigabytes. Classic pattern: shared/uploads linked into wp-content/uploads in each release.
WordPress staging and deployment strategies compared
In 2026, the path from a feature branch through a pull request and staging to main and production is not optional for shops and lead sites. Staging should share:
- the same PHP version and extensions as production
- a database copy (anonymised where personal data is in scope under GDPR/UK GDPR)
- payment secrets in sandbox mode only
Per-PR preview URLs help redesigns and CSS reviews more than schema migrations - those still need staging with a real dump.
| Strategy | Risk | Upkeep cost | When it fits |
|---|---|---|---|
| Manual FTP | Very high | Cheap start, expensive outages | Sandbox / learning only |
| Git hook on the server | Medium | Low | Small portfolio, one developer |
| CI + atomic rsync | Low | Medium | Business sites, WooCommerce, agencies |
| Blue/green or two VPS | Minimal | High | High traffic, SLA, payments |
Blue/green (two full environments, load-balancer flip) rarely pays below hundreds of concurrent sessions. Most mid-size UK shops do fine with an atomic symlink plus directory rollback.
Automated tests in a WordPress CI/CD pipeline
A pipeline without tests ships broken code faster. Four layers fit a WordPress repository, from fastest to slowest:
- Unit tests with PHPUnit check single functions of a plugin or theme without a database. They run on every push.
- Integration tests load WordPress against a temporary test database.
wp scaffold plugin-testsgenerates the scaffolding:phpunit.xml.dist,bin/install-wp-tests.shfor the test suite and database,tests/bootstrap.php, a sample test and a.phpcs.xml.distruleset. CircleCI is the default CI file;--ci=githubwrites a GitHub Actions configuration instead (WP-CLI: wp scaffold plugin-tests). - Static analysis with PHPStan and PHP_CodeSniffer catches type errors and coding standard violations without running the code.
- End-to-end tests with Playwright walk through real user flows: a contact form, the WooCommerce cart and checkout. They are the slowest layer, so run them on pull requests to
mainand against staging, not on every commit.
A red job in any layer stops the workflow before the artifact step, so nothing reaches the server.
Managing secrets and database migrations in WordPress CI/CD
Never commit:
- SSH keys
- database passwords
AUTH_KEY/SECURE_AUTH_KEYfromwp-config.php- payment gateway API tokens
In GitHub they live under Settings, then Secrets and variables, then Actions. Inject them as env in the deploy step. On the server, wp-config.php lives under shared/ and is not part of the CI artifact.
WP-CLI after the symlink flip:
wp cache flushwhen object cache is onwp rewrite flushafter CPT changeswp plugin listas a smoke line in the log
Run WP-CLI on the production server from the runner
These commands have to run on the server, against the new release. WP-CLI does that from the runner with the --ssh global parameter ([<scheme>:][<user>@]<host>[:<port>][<path>]) or with aliases in the project’s wp-cli.yml (WP-CLI handbook: running commands remotely):
@staging:
ssh: [email protected]/var/www/site/current/public
@prod:
ssh: [email protected]/var/www/site/current/publicWith that file in the repository, wp @prod cache flush runs on production. WP-CLI still has to be installed on the server and callable as wp: the runner only passes the command through SSH.
More commands worth adding to the post-switch step, all in the WP-CLI command reference:
wp core is-installedprints nothing and reports through its exit code, so a failed check fails the step.wp core update-db --dry-runcompares database versions before the realwp core update-dbafter a core update.wp core verify-checksumscompares core files with the WordPress.org checksums and catches files changed outside Git.wp maintenance-mode activateanddeactivatecover the rare release that must change the schema while nothing writes to it.
Plan database migrations apart from file deploys. Symlink rollback does not undo ALTER TABLE. For destructive migrations: backup first (wp db export on staging and production), migrate, then switch traffic to code that needs the schema - or gate it behind a PHP feature flag.
PHP dependency docs: Composer install - in CI always --no-dev for production and a locked composer.lock.
Common WordPress deployment pipeline errors
From VPS work (DigitalOcean, Linode, Hetzner) and managed hosts with SSH:
.gitignoreeatsvendor/- production gets a theme without an autoloader. Either build vendor on the runner and ship it in the artifact, or run Composer on the server after rsync (then the server needs Composer and Packagist access).- Wrong paths - the script assumes
/var/www/htmlwhile the host uses/home/user/domains/.... Pin paths in Secrets per environment. - Opcache keeps old code - after
ln -sfn, reload PHP-FPM or callopcache_resetvia WP-CLI/mu-plugin for the deploy user only. - Cron and queues - WooCommerce Action Scheduler can run jobs on old code for a few minutes; after a large release, watch failed jobs briefly.
Post-deploy smoke (curl from the runner or a separate job):
- homepage HTTP 200
/wp-login.php200- one critical product or form landing URL
- optional HEAD to the CDN if assets are content-hashed
If smoke fails, automatic symlink rollback costs less than a client call at 23:00.
Deploying block themes and mu-plugins with Git
Not every WordPress surface fits the same pipeline. Content in the database (posts, ACF field groups if you do not export JSON, Elementor CSS) still lives outside the release. CI/CD protects code; it does not replace database backups.
Practical splits:
- Classic or hybrid theme with Vite - full CI build, artifact with
style.css+dist/. - Block theme (FSE) - keep
theme.jsonand HTML templates in Git; Site Editor changes return via PR, or production and Git diverge after the first “Save”. - Must-use plugins - ship with the release; do not update them from the admin. Good place for deploy health checks and disabling the file editor.
- wordpress.org plugins - pin versions via Composer (
wpackagist) or update them in a reviewed job. Mixing “Update clicked in admin” with “CI overwroteplugins/” produces a random plugin tree.
Agency stacks often mix a custom theme, WooCommerce from wpackagist, and two premium zips installed via a Composer path repository. The pipeline must know all three sources or staging and production differ by one zip from six months ago.
WordPress continuous deployment checklist
Before wiring main to production, walk this list once - it saves a weekend:
- Private repo, or confirm history has no secrets (
git log -pdoes not show old.envfiles). - Branch protection: required PR, required green
testworkflow. - Separate Secrets:
DEPLOY_HOST_STAGING,DEPLOY_HOST_PROD, separate SSH keys. - Staging matches production PHP (not “I run 8.3, the client is on 8.1”).
- Database backup before the first atomic switch - even when code rollback is ready.
- Uptime monitoring (UptimeRobot, Better Stack, or similar) with SMS/Slack on the production URL.
- One-page runbook: how to
ln -sfnto the previous release by hand, who has server access.
Only then enable auto-deploy from main. Until then, “build + test” on PRs plus manual workflow_dispatch to staging is enough.
Summary
WordPress CI/CD is not “magic YAML”. It is a repeatable contract: same PHP, same lockfile, same artifact, a switch without a window of half-uploaded files. Start with Git and staging, add Actions for Composer/npm builds, finish with atomic current and a smoke test. FTP becomes an emergency tool, not the process.
If you need a WordPress developer who can keep that pipeline next to the theme code for a shop or lead site, WPPoland works from a real stack - not a slide deck.







