Staging, Version Control and Deploys for WordPress Teams
A practitioner’s guide to WordPress staging, Git workflow and atomic deployment: whitelist .gitignore, Composer plugins, migration runners and safe rollbacks.
- Code flows up (local to staging to production), content flows down (production to staging to local). Any workflow that moves the database upward on a live site will eventually destroy orders, comments or form entries.
- Use a whitelist
.gitignore, not a blacklist. Ignore the repository root by default and explicitly un-ignore your themes, your plugins and yourcomposer.json. Uploads never belong in Git. - Atomic deploys with a symlinked
currentdirectory give you a sub-second rollback. Useln -sfnT, because without-nyou’ll nest the new symlink inside the old directory and take the site down. - Schema and option changes need an idempotent migration runner invoked from the deploy script. A numbered folder of PHP files plus one option recording what has run is about 40 lines and solves 90 percent of the problem.
- Staging must block outbound mail, set
WPENVIRONMENTTYPE, setblog_publicto 0, and have PII scrubbed. A staging site that can email customers is a liability, not an environment.
Why WordPress resists Git (it’s the database)
Git is a content-addressed store for files. WordPress keeps roughly half of what defines a site in MySQL: posts, pages, menus, widget placement, permalink structure, active plugins, theme mods, ACF field values, WooCommerce products, and a sprawl of autoloaded options that plugins write without asking.
So you get two streams of change that move in opposite directions. Developers change code. Editors change content. If you treat the database as deployable you overwrite the editors’ work. If you treat it as untouchable you can never ship a feature that needs a new option, a new taxonomy term or a restructured custom field.
Page builders make this sharper. Elementor stores layouts in postmeta as JSON, Bricks does something similar, and both keep global styles in the options table. That means a “layout change” is a content change that lives in the database, and it cannot be code-reviewed, branched or merged. We’ve written about the trade-offs between the major builders elsewhere; the version control consequence is the one teams discover last. If your layouts live in postmeta, staging is for review only. You will be rebuilding changes by hand on production.

What goes in the repo, and what absolutely doesn’t
Start with a whitelist. Blacklist .gitignore files leak: someone adds a plugin, it gets committed with its own node_modules, and six months later your clone is 1.4 GB.
# .gitignore - ignore everything, then opt in
/*
!/.gitignore
!/.github
!/composer.json
!/composer.lock
!/package.json
!/package-lock.json
!/wp-content
/wp-content/*
!/wp-content/themes
/wp-content/themes/*
!/wp-content/themes/acme-child
!/wp-content/mu-plugins
!/wp-content/plugins
/wp-content/plugins/*
!/wp-content/plugins/acme-core
!/wp-content/plugins/acme-blocks
never, under any circumstance
/wp-content/uploads
/wp-content/cache
*.sql
*.sql.gz
.env
Third-party plugins are dependencies, not your source code. Manage them with Composer through wpackagist for anything on the .org repo, and a private Composer repository or the vendor’s own Composer endpoint for premium plugins. ACF Pro, Gravity Forms and WP Rocket all publish installable packages now, with a licence key in an environment variable.
{
"repositories": [
{ "type": "composer", "url": "https://wpackagist.org", "only": ["wpackagist-plugin/", "wpackagist-theme/"] }
],
"require": {
"php": ">=8.2",
"roots/wordpress": "6.9.*",
"wpackagist-plugin/wordpress-seo": "^26.0",
"wpackagist-plugin/query-monitor": "^3.17"
},
"extra": {
"wordpress-install-dir": "wp",
"installer-paths": {
"wp-content/plugins/{$name}/": ["type:wordpress-plugin"]
}
}
}
The payoff is that composer.lock pins every plugin to an exact version, so staging and production are provably identical. The cost is real: you lose one-click updates in the admin, and you need to teach the client that plugin updates now go through you. On sites where the client insists on self-serve plugin management, we skip Composer and commit the plugins directly. It’s uglier and it works.
A commercial theme sits in the middle. Commit your child theme, always. Keep the parent as a pinned dependency or a versioned zip in the repo, never as something an editor updates from the dashboard. For sites built on CanvasWP we commit the child theme plus a lockfile note of the parent version, which makes “it broke after the theme update” a question with an answer.
A WordPress Git workflow a team will actually follow
Gitflow is the wrong choice for almost every agency. Five long-lived branches on a project with two developers and a two-week cycle produces merge conflicts in composer.lock and nothing else of value.
What we run instead:
mainis production. Protected, no direct pushes, deploys automatically on merge.stagingis a deploy target, not a merge target. It tracks whatever branch is under review.- Short-lived feature branches off
main, namedfeat/,fix/orchore/, merged by pull request, deleted after merge. If a branch lives longer than five working days it’s too big.
One rule that has saved us repeatedly: every PR that touches the database must contain its migration file in the same commit. No exceptions, no “I’ll run it manually after”. Manual post-deploy steps are the single biggest cause of staging and production drifting apart.
Lint in CI, not in review. PHPCS with the WordPress standard plus PHPStan at level 5 catches the things humans waste review time on. Keep human review for architecture and for asking whether the feature should exist.
WordPress staging that tells the truth
A WordPress staging environment is worthless if it differs from production in ways you don’t track. We’ve debugged “works on staging, 500s on live” more times than anything else, and the cause is nearly always PHP version, an OPcache setting, an object cache that exists in one place only, or a plugin that’s active on one side.
Match these explicitly: PHP minor version, MySQL or MariaDB version, whether Redis or Memcached is present, the web server, and the list of active plugins. wp plugin list --status=active --field=name on both, diffed, takes four seconds and catches a surprising amount.
Then make staging safe. Four things, every time:
<?php
/**
- Plugin Name: Acme Environment Guards
- Drop in wp-content/mu-plugins/. Loaded before anything else.
*/
// Short-circuit all outbound mail outside production. 'prewpmail' lands in
// wp_mail() before any transport, so SMTP plugins can't route around it.
addfilter( 'prewpmail', function ( $shortcircuit, $atts ) {
if ( 'production' === wpgetenvironment_type() ) {
return $short_circuit;
}
$to = is_array( $atts['to'] ) ? implode( ',', $atts['to'] ) : $atts['to'];
error_log( sprintf( '[mail blocked] to=%s subject=%s', $to, $atts['subject'] ) );
return true; // tell callers it sent, so checkout flows don't error out
}, 10, 2 );
// Make it obvious in the admin bar which box you're looking at.
addaction( 'adminbar_menu', function ( $bar ) {
if ( 'production' === wpgetenvironment_type() ) {
return;
}
$bar->add_node( [
'id' => 'acme-env',
'title' => strtoupper( wpgetenvironment_type() ),
'meta' => [ 'class' => 'acme-env-flag' ],
] );
}, 100 );
Set define( 'WPENVIRONMENTTYPE', 'staging' ); in the staging config. Core has honoured it since 5.5 and plugins increasingly check it. Set blog_public to 0 so staging doesn’t get indexed, and put HTTP basic auth in front of it anyway, because noindex has never stopped a crawler that didn’t want to be stopped.
Also kill the schedulers. A staging clone of a WooCommerce site will happily start firing abandoned-cart jobs and subscription renewals against a copy of your real customer table. Action Scheduler and WP-Cron both need disabling, and if you want the detail on why WordPress’s scheduler behaves the way it does, we wrote that up separately.
Refreshing staging from production
# On production, via SSH
wp db export - --single-transaction --quick | gzip > /tmp/prod-$(date +%F).sql.gz
On staging
wp db reset --yes
gunzip < prod-2026-03-11.sql.gz | wp db import -
--precise forces PHP-side replacement so serialized arrays are rebuilt
correctly instead of left with wrong string lengths
wp search-replace 'https://acme.com' 'https://staging.acme.com' \
--all-tables-with-prefix --precise --recurse-objects --skip-columns=guid
wp option update blog_public 0
wp user update $(wp user list --role=administrator --field=ID | paste -sd' ') \
--user_pass="$(openssl rand -base64 24)"
wp plugin deactivate wp-rocket woocommerce-payments mailchimp-for-woocommerce
wp cache flush
--skip-columns=guid matters: GUIDs are permanent identifiers for feed readers, and rewriting them makes every post look new. The admin password reset matters because clients reuse passwords and staging gets shared around.
PII scrubbing is the part people skip. For anything with customer records, run an anonymisation pass that rewrites emails to user+{ID}@example.invalid, blanks phone numbers and truncates address fields. Write it as a WP-CLI command and bake it into the refresh script so it can’t be forgotten. If that sounds like a lot of work for a staging site, consider what a staging site leaking 40,000 customer emails costs. If terminal workflows aren’t your comfort zone yet, our WP-CLI primer covers the basics.
Deploys: atomic, reversible, boring
The target for WordPress deployment is a release that’s either fully live or not live at all, with no window where half the files are new. Rsyncing straight into the document root fails this: for two to ten seconds you’re serving a theme whose template files are mid-update.
Symlinked releases fix it. Directory layout on the server:
/var/www/acme/
releases/20260311142200/
releases/20260310093115/
shared/uploads/
shared/.env
current -> releases/20260311142200
Your web root points at current/. The swap is one symlink operation.
name: Deploy production
on:
push:
branches: [main]
concurrency:
group: deploy-production # never two deploys at once
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2
- run: composer install --no-dev --prefer-dist --optimize-autoloader
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci && npm run build
- name: SSH setup
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "${{ secrets.DEPLOYKEY }}" > ~/.ssh/ided25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H ${{ secrets.SSHHOST }} >> ~/.ssh/knownhosts
- name: Ship it
env:
HOST: ${{ secrets.SSHUSER }}@${{ secrets.SSHHOST }}
run: |
set -euo pipefail
REL=$(date +%Y%m%d%H%M%S)
rsync -az --delete \
--exclude '.git*' --exclude 'node_modules' --exclude 'wp-content/uploads' \
./ "$HOST:/var/www/acme/releases/$REL/"
ssh "$HOST" bash -se <<EOF
set -euo pipefail
cd /var/www/acme
ln -s shared/uploads releases/$REL/wp-content/uploads
ln -s /var/www/acme/shared/.env releases/$REL/.env
# -T is the important flag: without -n/-T you create
# current/$REL instead of replacing the symlink
ln -sfnT releases/$REL current
wp --path=current/wp core update-db --skip-plugins --skip-themes
wp --path=current/wp acme migrate
wp --path=current/wp cache flush
sudo systemctl reload php8.3-fpm
ls -1dt releases/* | tail -n +6 | xargs -r rm -rf
EOF
That keeps five releases. Rollback is ln -sfnT releases/<previous> current and an FPM reload, which is about 300ms of work. Build assets in CI, never on the production box: you don’t want Node, a package registry outage or a 600 MB node_modules anywhere near a live server.
If you’re on a managed host with Git push deploys (Kinsta, WP Engine, Pantheon), use theirs. They’re atomic enough and the operational burden is lower. Just read the docs on what their “push to staging” button does to the database, because several of them sync the whole thing by default and that’s how an afternoon of editorial work disappears.
Database changes: the part nobody solves properly
Code deploys are a solved problem. Getting a new option, a reorganised custom field or a backfill onto production without someone running SQL in phpMyAdmin at 11pm is not.
Treat database changes like Laravel treats migrations: numbered, idempotent, run from the deploy script, recorded.
<?php
/**
- Plugin Name: Acme Deploy Migrations
- wp-content/mu-plugins/acme-migrations/index.php
*/
if ( ! defined( 'WPCLI' ) || ! WPCLI ) {
return;
}
WPCLI::addcommand( 'acme migrate', function () {
$done = (array) getoption( 'acmemigrations_run', [] );
$files = glob( __DIR__ . '/migrations/*.php' );
sort( $files, SORT_NATURAL );
foreach ( $files as $file ) {
$id = basename( $file, '.php' );
if ( in_array( $id, $done, true ) ) {
continue;
}
$migration = require $file; // each file returns a closure
$migration();
$done[] = $id;
// Persist after every migration, not at the end: a timeout halfway
// through must not re-run the ones that already succeeded.
updateoption( 'acmemigrations_run', $done, false );
WP_CLI::success( "Migrated {$id}" );
}
WP_CLI::log( 'Migrations up to date.' );
} );
A migration file, migrations/0012-split-hero-field.php:
<?php
return function () {
$ids = get_posts( [
'post_type' => 'page',
'postsperpage' => -1,
'fields' => 'ids',
'metakey' => 'heroblob',
] );
foreach ( $ids as $id ) {
$blob = getpostmeta( $id, 'hero_blob', true );
if ( ! is_array( $blob ) ) {
continue;
}
updatepostmeta( $id, 'hero_heading', $blob['heading'] ?? '' );
updatepostmeta( $id, 'hero_sub', $blob['sub'] ?? '' );
deletepostmeta( $id, 'hero_blob' );
}
};
Every migration must survive being run twice. Guard with if ( getoption( ... ) ) checks, use update rather than add_, and skip rows that don’t match the expected shape instead of throwing.
For ACF, turn on local JSON (acf-json/ inside your theme) so field group definitions become reviewable files in Git rather than database rows. Same idea for Gutenberg: register block types from block.json, keep patterns in PHP via registerblockpattern(), and define theme styles in theme.json. Anything you can push from a file instead of syncing from a database, push from a file. If you’re building custom blocks, our guide to doing it without losing your mind covers the registration side.
What you cannot migrate cleanly: menus, widget placement in classic sidebars, and builder layouts. Accept it. Those get rebuilt on production by a human, from a staging screenshot, during a quiet hour.
Rollbacks, pre-flight and when to ignore all of this
Our pre-deploy checklist is deliberately short, because long checklists get skipped:
- Database dump taken within the last 15 minutes, and you have verified the file is non-zero and gzip-valid. An unverified backup is not a backup.
- Migrations run successfully on a fresh copy of the production database, locally, not just on the staging data from three weeks ago.
- Nobody is mid-edit on a big piece of content. On editorial sites we deploy before 9am or after 7pm, local time.
- You know which symlink to point back at.
Code rollbacks are instant and safe. Database rollbacks are neither: between your deploy and your rollback, real orders and real comments arrived, and restoring a dump throws them away. This is the asymmetry that should drive how you write migrations. Prefer additive changes. Write the new meta key, read from it with a fallback to the old one, and delete the old key in a separate deploy a week later. Two deploys, no rollback risk. We’ve shipped that pattern on sites with 200,000 posts and the backfill ran in the background over hours without a single page of downtime.
Now the honest caveat. All of this is overhead, and on a five-page brochure site maintained by one person it’s overhead with no payoff. If there’s one developer, no editorial team and no custom code beyond a child theme, a managed host’s one-click staging plus automated daily backups plus a Git repo for the child theme is the right amount of process. Go further than that and you’ll spend more time maintaining the pipeline than the site.
The threshold where this pays for itself, in our experience: two or more people touching code, or custom plugins, or any transactional data. Hit any one of those and the full workflow is cheaper than the first incident you’d have had without it.
Frequently Asked Questions
Should I commit the WordPress core files to Git?
Only if you’re not using Composer. Core is a dependency, so pinning it with roots/wordpress in composer.json is cleaner and makes version bumps a one-line diff rather than a 2,000-file commit. If Composer isn’t an option for the project, committing core is acceptable and still far better than no version control, just be disciplined about not editing anything inside it.
Can I push a database from staging to production?
On a brand new site before launch, yes, and that’s the only safe case. After launch, production holds data that exists nowhere else: orders, comments, form submissions, new media, editor changes made ten minutes ago. Pushing a staging database overwrites all of it, and no amount of care makes that reliable. Move specific records or run a migration instead.
What’s the best local environment for a WordPress team in 2026?
DDEV if your team is comfortable with Docker, because the .ddev/config.yaml lives in the repo so everyone gets identical PHP, MySQL and extension versions from one ddev start. Local by WP Engine is the pragmatic choice for mixed teams with designers who don’t want a terminal. wp-env is excellent specifically for block and plugin development against Gutenberg trunk, less so for full client sites.
How do I handle uploads between environments?
Never put wp-content/uploads in Git, and never rsync a 40 GB media library down to local. Symlink uploads to a shared directory on the server so deploys don’t touch them, and for local work either proxy missing files to production with a plugin like BE Media from Production, or pull only the last 30 days of uploads. Sites using S3 or Cloudflare R2 offloading get this for free since all environments read the same bucket.
Do I need CI if my host offers Git push deploys?
If your build step is trivial and you have no tests, host-native Git deploys are enough and you should use them. Add CI once you have an asset build, a linting standard, Composer dependencies, or migrations that need running in a specific order after files land. The deciding question is whether anything needs to happen between the push and the site going live.
Where to start
Pick the one that matches where you are. No repo at all: initialise one this week with the whitelist .gitignore, commit the theme and custom plugins, and stop there. Repo but FTP deploys: add the symlinked release structure and a GitHub Actions workflow, which is an afternoon’s work and removes your worst remaining failure mode. Already deploying from CI but still running SQL by hand: build the migration runner above, which is the piece that finally makes database changes reviewable.
Do one of those. Not all three at once, and not on a Friday.


