CI/CD for WordPress: automating your deployment in 2026

CI/CD for WordPress: automating your deployment in 2026

Last verified: October 15, 2026
13 min read
Guide
Full-stack developer

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:

  1. Environment drift - local PHP 8.2 and Composer 2.7, VPS on PHP 8.1 without intl. Local composer install “works”; production dies on class not found.
  2. No audit trail - after an incident you cannot say which commit was live. The host panel shows a file date, not a Git SHA.
  3. Secrets on disk - SFTP passwords saved in FileZilla, or a .env committed “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:

  1. Trigger - push to main or merge a reviewed pull request. develop often deploys only to staging.
  2. 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').
  3. Test - PHPUnit for plugins, optionally Playwright on the checkout path. Fail stops the job; no deploy.
  4. Artifact - pack a release without .git, without node_modules, with vendor/ and built assets/dist/.
  5. 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 -sfn

Keep 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: false

Because 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 main or release tags may deploy to production, 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_KEY here 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 @v2 can 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_TOKEN the minimum. Set permissions: contents: read at 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.

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/current

Nginx 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.

StrategyRiskUpkeep costWhen it fits
Manual FTPVery highCheap start, expensive outagesSandbox / learning only
Git hook on the serverMediumLowSmall portfolio, one developer
CI + atomic rsyncLowMediumBusiness sites, WooCommerce, agencies
Blue/green or two VPSMinimalHighHigh 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-tests generates the scaffolding: phpunit.xml.dist, bin/install-wp-tests.sh for the test suite and database, tests/bootstrap.php, a sample test and a .phpcs.xml.dist ruleset. CircleCI is the default CI file; --ci=github writes 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 main and 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_KEY from wp-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 flush when object cache is on
  • wp rewrite flush after CPT changes
  • wp plugin list as 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/public

With 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-installed prints nothing and reports through its exit code, so a failed check fails the step.
  • wp core update-db --dry-run compares database versions before the real wp core update-db after a core update.
  • wp core verify-checksums compares core files with the WordPress.org checksums and catches files changed outside Git.
  • wp maintenance-mode activate and deactivate cover 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:

  • .gitignore eats vendor/ - 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/html while the host uses /home/user/domains/.... Pin paths in Secrets per environment.
  • Opcache keeps old code - after ln -sfn, reload PHP-FPM or call opcache_reset via 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.php 200
  • 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.json and 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 overwrote plugins/” 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:

  1. Private repo, or confirm history has no secrets (git log -p does not show old .env files).
  2. Branch protection: required PR, required green test workflow.
  3. Separate Secrets: DEPLOY_HOST_STAGING, DEPLOY_HOST_PROD, separate SSH keys.
  4. Staging matches production PHP (not “I run 8.3, the client is on 8.1”).
  5. Database backup before the first atomic switch - even when code rollback is ready.
  6. Uptime monitoring (UptimeRobot, Better Stack, or similar) with SMS/Slack on the production URL.
  7. One-page runbook: how to ln -sfn to 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.

Next step

Turn the article into an actual implementation

This block strengthens internal linking and gives readers the most relevant next move instead of leaving them at a dead end.

Want this implemented on your site?

If you want to convert the article into a working site improvement, redesign, or build plan, I can define the scope and implement it.

Explore other WordPress services and knowledge base

Strengthen your business with professional technical support in key areas of the WordPress ecosystem.

Frequently asked questions

Practical answers to apply the topic in real execution.

Does CI/CD make sense for a solo freelancer?#
Yes. Solo developers lose the most from a forgotten PHP include or a composer.lock built on a different PHP version. The pipeline forces the same build on every commit and leaves an auditable log of which SHA went live.
What is zero-downtime deployment in WordPress?#
New code lands in releases/TIMESTAMP. Only after a successful rsync and optional wp cache flush do you flip the current symlink. PHP-FPM or Nginx serve through current, so the switch takes milliseconds and needs no maintenance mode.
Do I need a VPS for CI/CD?#
A VPS or containers give full control (SSH, symlinks, WP-CLI). On managed hosts you often get a deploy API or SFTP with a key in Secrets - still better than dragging files in FileZilla, even without true blue/green.
What if a deploy fails?#
Keep N previous release directories. If the smoke test returns 500 or vendor/ is missing, point the symlink back at the previous release. Plan database migrations separately - rolling back code does not undo ALTER TABLE.
Where should Vite or webpack themes be built?#
On the CI runner: checkout, setup Node, npm ci, npm run build, then pack dist/ into the artifact with PHP. Do not ship node_modules or TypeScript sources to production - only compiled CSS/JS.

Need an FAQ tailored to your industry and market? I can build one aligned with your business goals.

Let’s discuss

Related Articles