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 FTP and “just upload it” fail audits
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.
Minimal pipeline: push, build, test, deploy
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.2',node-version: '20'). - 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.
Short GitHub Actions sketch:
name: deploy-production
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- run: composer install --no-dev --optimize-autoloader
- uses: actions/setup-node@v4
with:
node-version: '20'
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.
Atomic deploys instead of overwriting files
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→ 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.
Staging, previews and a strategy matrix
In 2026, feature branch → PR → staging → main/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.
Secrets, WP-CLI and the database
Never commit:
- SSH keys
- database passwords
AUTH_KEY/SECURE_AUTH_KEYfromwp-config.php- payment gateway API tokens
In GitHub: Settings → Secrets and variables → 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
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.
What usually breaks the first WordPress pipeline
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.
Block themes, must-use plugins and Git exceptions
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.
Checklist before the first Actions deploy
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 “we 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.







