Staging is not one copy. It is two copies that behave differently — the files and the database — plus a rule about what is allowed to travel back to production. Most staging accidents I have seen were not broken code. They were a database import that carried production's user table into staging, or staging's wp_options table into production.
This is the setup I use: clone files, clone the database, rewrite the URLs, make staging unable to hurt anyone, then sync back with a narrow allowlist.
Step 0 — write the boundary down before you copy anything
One rule covers almost every case:
Content a human wrote and code a human reviewed may travel. Session state, credentials, environment identity, and anything a customer typed may not.
If you cannot say which side of that line a table is on, it does not sync.
Step 1 — clone the files
cd /var/www/example.com
rsync -a \
--exclude 'wp-content/cache/' \
--exclude 'wp-content/uploads/' \
--exclude '.git/' \
./ /var/www/staging.example.com/
Uploads are excluded on purpose. Copy them once, separately, without overwriting anything already on staging:
rsync -a --ignore-existing \
/var/www/example.com/wp-content/uploads/ \
/var/www/staging.example.com/wp-content/uploads/
--ignore-existing matters. If you sync uploads in the other direction later with --delete, you will happily delete an image that marketing replaced by hand.
Step 2 — clone the database, then rewrite the URLs
cd /var/www/example.com
wp db export /tmp/prod-$(date +%F-%H%M).sql
mysql -e "DROP DATABASE IF EXISTS staging_wp; CREATE DATABASE staging_wp CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mysql staging_wp < /tmp/prod-$(date +%F-%H%M).sql
Point staging at the new database:
wp --path=/var/www/staging.example.com config set DB_NAME staging_wp
wp --path=/var/www/staging.example.com config set DB_USER staging_user
wp --path=/var/www/staging.example.com config set DB_PASSWORD 'change-me'
Now the URL rewrite. Use a serialisation-aware tool, not UPDATE ... REPLACE():
wp --path=/var/www/staging.example.com search-replace \
'https://example.com' 'https://staging.example.com' \
--all-tables-with-prefix \
--skip-columns=guid \
--report-changed-only
Two details that people get wrong:
-
--skip-columns=guid—wp_posts.guidis the permanent identifier feed readers use. Rewriting it makes every existing item look new. Leave it alone. -
Never use a raw SQL
REPLACE()on a WordPress database. Serialised values store their string length; changing the domain inside them without fixing the length corrupts the row and you find out weeks later when a widget renders as an empty box.
If you have custom tables with a different prefix, swap --all-tables-with-prefix for --all-tables. Run it with --dry-run first if you have never run it against this site.
Step 3 — make staging harmless
Staging that can send email, charge cards, or get indexed is not staging. It is a second production site with a worse name.
In wp-config.php on staging:
// Above the "That's all, stop editing" line.
define( 'WP_ENVIRONMENT_TYPE', 'staging' );
define( 'DISABLE_WP_CRON', true );
Then a must-use plugin, so the guards survive theme and plugin changes:
<?php
// wp-content/mu-plugins/staging-guard.php
if ( 'staging' !== wp_get_environment_type() ) {
return;
}
add_action( 'send_headers', function () {
header( 'X-Robots-Tag: noindex, nofollow, noarchive', true );
} );
add_filter( 'pre_wp_mail', function ( $short_circuit, $atts ) {
$to = is_array( $atts['to'] ) ? implode( ',', $atts['to'] ) : $atts['to'];
error_log( '[staging] outgoing mail blocked -> ' . $to );
return true; // short-circuit: wp_mail() reports success, nothing is sent
}, 10, 2 );
The mail block is the single highest-value line in this article. Without it, a staging site restored from production will eventually email real customers with a test order confirmation or a password reset pointing at staging.example.com.
Put basic auth in front of the whole host as well, so the noindex header is not your only defence:
server {
server_name staging.example.com;
auth_basic "staging";
auth_basic_user_file /etc/nginx/.htpasswd;
# ... rest of the server block
}
printf "dev:%s\n" "$(openssl passwd -apr1)" >> /etc/nginx/.htpasswd
Finally, in the database: replace live payment credentials with test ones, and disable every plugin that writes to a live CRM or mailing list. If a plugin has its own queue table, empty it.
Verify:
curl -sI https://staging.example.com | grep -i x-robots-tag
# expect: x-robots-tag: noindex, nofollow, noarchive
The sync matrix
This is the part to keep next to the ticket.
| Data | staging → production | production → staging |
|---|---|---|
| Theme / child theme files | Yes, after a reviewed diff | Yes |
| Custom plugin files | Yes, after a reviewed diff | Yes |
| Posts and pages you authored | Yes, by slug, one at a time | Yes |
| New media files only | Yes, with --ignore-existing
|
Yes |
| Comments | Rarely | Yes, if you need realistic data to test |
wp_users, wp_usermeta
|
Never | Never |
| Orders, customers, subscriptions, carts | Never | Never |
| Form entries, CRM leads | Never | Never |
siteurl, home
|
Never | Never |
active_plugins |
Never | Never |
| Permalink structure / rewrite rules | Never | Never |
| Cron option, transients, object cache | Never | Never |
| SMTP, API keys, licence keys, payment keys | Never | Into a gitignored file, never into the DB |
| Analytics, tag manager, consent IDs | Never | Never |
Why wp_users is a hard no in both directions: password hashes, roles, capabilities and session tokens travel with the row. Pushing staging users to production can promote an account. Pulling production users to staging spreads real credentials into an environment with weaker access control.
If you need to log in to staging, create a local admin there:
wp --path=/var/www/staging.example.com user create dev \
dev@example.invalid --role=administrator --user_pass="$(openssl rand -base64 18)"
Recipe A — code-only push (the default)
rsync -av --dry-run --delete \
--exclude '.git/' --exclude 'node_modules/' \
/var/www/staging.example.com/wp-content/themes/acme-child/ \
/var/www/example.com/wp-content/themes/acme-child/
Read the dry-run output. Then run the identical command without --dry-run. Then:
wp --path=/var/www/example.com cache flush
This is the only sync that is safe on a Friday afternoon, and it covers the majority of real work.
Recipe B — push one page's content
Post IDs differ between environments, so match on slug:
SLUG='shipping-and-returns'
STAGE=/var/www/staging.example.com
PROD=/var/www/example.com
STAGE_ID=$(wp --path="$STAGE" post list --post_type=page --name="$SLUG" --field=ID)
PROD_ID=$(wp --path="$PROD" post list --post_type=page --name="$SLUG" --field=ID)
echo "staging=$STAGE_ID production=$PROD_ID"
wp --path="$STAGE" post get "$STAGE_ID" --field=post_content > /tmp/page.html
diff -u <(wp --path="$PROD" post get "$PROD_ID" --field=post_content) /tmp/page.html || true
# after reading the diff
wp --path="$PROD" post update "$PROD_ID" /tmp/page.html
This replaces post_content and nothing else. If the page uses custom fields, compare those too:
wp --path="$STAGE" post meta list "$STAGE_ID"
Recipe C — full database push, only when production had zero writes
A whole-database push is legitimate in exactly one situation: production has received no orders, no signups, no comments and no content edits since you took the staging copy. If you cannot state that with confidence, use Recipe A or B.
Checklist, in order:
wp --path="$PROD" maintenance-mode activate- Take a backup and confirm it is real:
wp --path=/var/www/example.com db export /root/prod-before-push-$(date +%F-%H%M).sql
ls -lh /root/prod-before-push-*.sql
- Restore that backup into a scratch database and load the homepage from it. A backup you have not restored is a hope, not a backup.
- Push the staging database over the production one.
- Rewrite the URLs in the other direction:
wp --path=/var/www/example.com search-replace \
'https://staging.example.com' 'https://example.com' \
--all-tables-with-prefix --skip-columns=guid --report-changed-only
wp --path=/var/www/example.com cache flush
-
wp --path="$PROD" maintenance-mode deactivate, then load a product page and a form immediately.
Expect session loss: carts, logged-in sessions and password-reset links issued before the push are gone. That is why this belongs in a quiet window, not at 11am.
Post-sync checklist
-
wp option get homeandwp option get siteurl— correct environment. -
wp plugin list --status=active— no debug or profiling plugin that was only meant for staging. - Stray URLs:
wp --path=/var/www/example.com db query \
"SELECT COUNT(*) FROM wp_options WHERE option_value LIKE '%staging.example.com%';"
- Load the homepage, one deep page, one form, one product.
- Submit the form and confirm it lands where you expect.
-
curl -sI https://example.com— there must be noX-Robots-Tag: noindex. -
wp cron event list— no duplicated events. If you disabled cron during the push,wp cron event run --due-now. - Take a fresh backup once you are satisfied. The pre-push one is no longer current.
The five things people sync by accident
-
wp_users— password hashes, roles and sessions ride along. -
wp_optionsas a whole — it mixes environment identity, credentials, cron schedules and throwaway transients in one table. - The uploads folder, in the wrong direction — overwrites hand-edited images.
- The cron option — the same job then runs twice, or runs against the wrong data.
-
wp-config.phpand.htaccess— they carry database credentials and environment-specific rules.
If you get the boundary right, staging becomes boring. Boring is the goal.
Disclosure: I work at AumCreate, a small WordPress shop in Singapore. We deploy and maintain sites on the client's own host; if you would rather not run this yourself, that service is described here: aumcreate.com/setup.
Top comments (0)