Most WordPress sites I inherit are deployed the same way: someone opens an SFTP client, drags a folder onto the server, and prays. I call it the midnight SFTP problem, because that’s when it usually happens — a hotfix at 11:47 PM, no backup, no record of what changed, and a live site that’s down for the ninety seconds it takes to overwrite a plugin directory. I’ve been shipping production systems for 28 years, and I run a fleet of WordPress sites. None of them are deployed that way. Here’s the pipeline I build instead, end to end.
The problem with how WordPress usually ships
The default WordPress workflow has no version control, no staging that actually mirrors production, and no atomic switch — files are overwritten in place, so for the duration of the copy the site is serving a half-old, half-new tree. Add plugin auto-updates changing your code behind your back and an uploads directory tangled up with your application code, and you have a system nobody can reason about. The fix is to treat a WordPress site like the software it is: put it under version control, build it in CI, and release it atomically with a way back.
Step 1 — Put WordPress under version control with Composer
WordPress core, your plugins, and your themes are dependencies. Treat them like dependencies. I manage them with Composer and pull plugins from wpackagist, which mirrors the plugin and theme directories as Composer packages. The repository holds a composer.json and your custom theme — never core, never third-party plugins, and never wp-content/uploads.
{
"repositories": [
{ "type": "composer", "url": "https://wpackagist.org" }
],
"require": {
"johnpbloch/wordpress": "6.8.*",
"wpackagist-plugin/wordpress-seo": "^24.0",
"wpackagist-plugin/wp-graphql": "^2.0",
"wpackagist-theme/twentytwentyfive": "^1.0"
},
"extra": {
"wordpress-install-dir": "wp",
"installer-paths": {
"wp-content/plugins/{$name}/": ["type:wordpress-plugin"],
"wp-content/themes/{$name}/": ["type:wordpress-theme"]
}
}
}
Now composer install reproduces the exact same site on any machine. Versions are pinned in composer.lock and reviewed in pull requests. The .gitignore does the other half of the job:
/wp/
/wp-content/plugins/
/wp-content/themes/twentytwentyfive/
/wp-content/uploads/
wp-config.php
.env
Staging that actually mirrors production
A staging environment is only useful if it’s the same OS, same PHP version, same MySQL version, and the same plugin set as production. Because the site is now built from composer.lock, that’s free: staging runs the identical lockfile. The only differences are credentials and the database, which I pull down from production nightly and run through wp search-replace (more on that below). If it works on staging, it works in production — that’s the entire point of doing this.
Step 2 — The CI/CD pipeline
Every push to main triggers a build. The job installs dependencies, runs linting and tests, then ships only on green. Here’s the core of a GitHub Actions workflow:
name: deploy
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.3'
tools: composer, wp-cli
- name: Build
run: composer install --no-dev --optimize-autoloader --no-interaction
- name: Lint & test
run: |
composer exec phpcs -- --standard=WordPress wp-content/themes/ce
composer exec phpunit
- name: Deploy
env:
SSH_KEY: ${{ secrets.DEPLOY_KEY }}
run: ./bin/deploy.sh production
The build runs --no-dev so PHPUnit and PHP_CodeSniffer never reach the server. Tests run before a single byte is copied. If phpcs or phpunit fails, the deploy step never runs.
Backup before you touch anything
The first thing deploy.sh does on the server is snapshot the database. This is non-negotiable — it’s the difference between a five-second rollback and a recovery ticket.
#!/usr/bin/env bash
set -euo pipefail
RELEASE="$(date +%Y%m%d%H%M%S)"
APP=/var/www/site
ssh "$DEPLOY_HOST" "wp db export $APP/backups/pre-$RELEASE.sql \
--path=$APP/current --add-drop-table"
Atomic releases with a symlink
The release goes into a fresh, timestamped directory. Nothing in the live path changes until the very last step, when a single ln -sfn flips the current symlink. A symlink swap is atomic — there is no moment where a request sees a half-deployed tree. That’s how you get zero-downtime deploys.
RELEASES=$APP/releases
rsync -az --delete ./ "$DEPLOY_HOST:$RELEASES/$RELEASE/"
# uploads and wp-config live outside the release, shared across all of them
ssh "$DEPLOY_HOST" "
ln -sfn $APP/shared/uploads $RELEASES/$RELEASE/wp-content/uploads
ln -sfn $APP/shared/wp-config.php $RELEASES/$RELEASE/wp-config.php
"
Run migrations, then flip and health-check
Database-affecting steps run against the new release directory before the symlink flips. Then we flip, hit a health endpoint, and roll back automatically if it doesn’t come back 200.
CUR=$APP/current
PREV=$(ssh "$DEPLOY_HOST" "readlink $CUR")
ssh "$DEPLOY_HOST" "
wp core update-db --path=$RELEASES/$RELEASE
ln -sfn $RELEASES/$RELEASE $CUR
wp cache flush --path=$CUR
"
# Health check — auto-rollback on failure
CODE=$(curl -s -o /dev/null -w '%{http_code}' https://example.com/wp-json/ce/v1/health)
if [ "$CODE" != "200" ]; then
echo "Health check failed ($CODE) — rolling back to $PREV"
ssh "$DEPLOY_HOST" "ln -sfn $PREV $CUR && wp cache flush --path=$CUR"
exit 1
fi
echo "Deployed $RELEASE"
Rollback is just re-pointing the symlink at the previous release directory — instant, and the database backup from the start of the run covers the schema. I keep the last five releases on disk and prune the rest.
Database and media gotchas
This is where WordPress deployments earn their reputation, so be deliberate here.
- URLs live in the database, not in config. When you copy production data to staging, the production domain is baked into thousands of rows. Fix it with
wp search-replace 'https://example.com' 'https://staging.example.com' --all-tables. Never run a raw SQLUPDATEfor this. - Serialized data will break a naive find-and-replace. WordPress stores widget settings and theme options as PHP-serialized strings with byte-length prefixes. A plain string replace corrupts those length counts and silently breaks options.
wp search-replacedeserializes, replaces, and re-serializes correctly — always use the WP-CLI tool, and add--dry-runfirst to see the row counts before committing. - Never commit
wp-content/uploads. Media is user data, not code. It belongs in object storage or a shared directory symlinked into every release, backed up on its own schedule. Committing it bloats the repo and guarantees merge pain. - Plugin auto-updates fight the pipeline. If WordPress updates a plugin on the live server, your next deploy reverts it to the locked version and the change vanishes — or worse, the versions diverge. Disable auto-updates entirely with
define('AUTOMATIC_UPDATER_DISABLED', true);andadd_filter('automatic_updater_disabled', '__return_true');. Plugin versions are bumped incomposer.json, reviewed, and shipped through CI like everything else.
The fleet angle
The real payoff shows up at scale. When you run a dozen WordPress sites, the worst outcome is a dozen snowflakes — each configured by hand, each a mystery. Because every site here is the same shape — Composer build, identical pipeline, atomic releases, the same health-check contract — adding the fifteenth site costs almost nothing. A security patch to a shared plugin is one version bump propagated across the fleet through pull requests, each one tested and rolled out the same way. Standardizing on one disciplined automated WordPress deployment pattern is what makes a fleet manageable instead of a liability.
What you actually get
Once this is in place, deploys stop being events. A merge to main builds, tests, backs up, ships atomically, health-checks, and rolls itself back if anything is off — with a full git history of every change and a one-command path to any prior release. No SFTP, no downtime, no midnight prayers.
At Champlin Enterprises we build pipelines exactly like this for WordPress fleets — version-controlled, zero-downtime, and self-rolling-back. If your sites still ship over SFTP, let’s talk.





