Push Staging to Live in WordPress Without Losing Orders

Push Staging to Live in WordPress Without Losing Orders

If you push staging to live by copying the whole database across, you overwrite every order, customer account, comment and form entry your site has collected since you made the clone. The safe version of this job is two jobs with different rules: move files wholesale, and move database changes one table at a time, deliberately.

What makes this trap easy to fall into is that staging tools use one word for both operations. “Push” in a plugin UI can mean rsyncing a theme folder — repeatable, reversible, boring — or replacing wp_posts, wp_users and your order tables with a snapshot taken three weeks ago. The first is deployment. The second is a restore from the wrong backup.

A staging database is a photograph, not a branch

Git merges two sets of changes because it tracks the changes. A database clone tracks nothing — it is a copy of rows at one moment. There is no three-way merge for wp_posts, no conflict markers, and no way for an importer to tell “row that changed on staging” from “row that changed on live”. Whichever copy you import last simply wins.

What a push actually moves: files versus database

Split the job along this line before you touch anything, because the two halves have opposite risk profiles and want opposite tools.

Half of the pushWhat lives thereSafe to overwrite?What goes wrong
Fileswp-content/themes/, wp-content/plugins/, mu-plugins/, custom code, core itselfYes. Overwrite freely, re-run as often as you likeVery little, as long as you exclude wp-content/uploads/ and wp-config.php
DatabasePosts, pages, users, orders, comments, options, template parts, scheduled jobs, sessionsNo, unless nothing but you writes to the live siteSilent destruction of everything created on live since the clone. No error, no warning
Uploadswp-content/uploads/Copy up from live, never down from stagingPushing staging uploads over live deletes nothing but misses every file uploaded since the clone, leaving broken images in new posts
Uploads are technically files but behave like data, because live visitors and editors add to them. Treat them as a third category.

Worth knowing before you shop for a tool: the free tiers of the popular staging plugins deliberately stop at the safe half. WP Migrate Lite 2.7.12 gives you SQL export, full-site ZIP export and a serialisation-aware find-and-replace, but push and pull between two live installs are paid features. WP STAGING 4.19.0 free clones production into a subdirectory; pushing the clone back to production is in the Pro tier. That is not purely commercial packaging — the one-click push is the dangerous direction, and it is the one they put behind support.

What your live database collected while you were on staging

This is the list to read before you decide anything. Every row below was written by someone who is not you, on the live site, after your clone was taken.

TablesWhat live wrote thereCost of overwriting it
wp_wc_orders, wp_wc_orders_meta, wp_wc_order_addresses, wp_wc_order_operational_dataEvery order taken since the clone, on a store using High-Performance Order StorageUnrecoverable without a backup. This is money and legal records, not content
wp_woocommerce_order_items, wp_woocommerce_order_itemmetaLine items, shipping lines, tax lines and fees for those ordersOrders survive in name and lose their contents, which is arguably worse than losing them outright
wp_posts, wp_postmetaNew posts and pages, product reviews, and — on a store still on legacy order storage — the orders themselvesPublished content disappears; editors assume they are losing their minds
wp_users, wp_usermetaCustomer registrations, password changes, changed email addresses, user roles granted since the cloneNew customers cannot log in and have no account to reset. Support load lands the same afternoon
wp_comments, wp_commentmetaComments and product reviews, approved and pendingReview counts and ratings reset. On a store, star ratings are a conversion asset
wp_wc_order_stats, wp_wc_customer_lookup, wp_wc_order_product_lookup, wp_wc_order_tax_lookup, wp_wc_order_coupon_lookupWooCommerce Analytics lookup rows derived from live ordersReports disagree with orders. Rebuildable, but only after the orders themselves are intact
wp_actionscheduler_actions, wp_actionscheduler_claims, wp_actionscheduler_groupsQueued and in-flight background jobs: emails, subscription renewals, webhook retries, importsPending work vanishes or re-runs. Duplicate renewal charges live here
wp_woocommerce_sessions, wp_wc_reserved_stockActive carts and stock held for checkouts in progressShoppers mid-checkout lose their basket. Short-lived, but it is the worst moment to do it
wp_woocommerce_payment_tokens, wp_woocommerce_payment_tokenmetaSaved payment methods tokenised by your gatewayStored cards stop working. Saved-card checkouts and subscriptions fail at the gateway
wp_optionsSettings changed on live, the cron array, transients, and license keys bound to the live domainMixed bag, and the one people forget. Details below
Table names use the default wp_ prefix. WooCommerce table names per WooCommerce’s installed-database-tables documentation; Action Scheduler names per its store schema class.

wp_options deserves its own warning, because it is the table everyone assumes is “just settings”. It holds the serialised cron array, so importing a staging copy replaces your live schedule with whatever staging was running — usually nothing, because staging sites get no traffic to fire cron. It holds every transient, including the ones your plugins use as locks. And it holds the license keys and API credentials that your paid plugins registered against the live hostname, which is how a push ends with a working site that cannot check for updates. If you have not looked at this table recently, auditing and trimming WordPress autoloaded options is a good way to find out what has accumulated in it.

How I decide whether a full database push is safe

Six questions, in this order. A single “no” on the first three ends the discussion, and I would rather be clear about that than hedge.

Does anyone but you write to the live database? Orders, registrations, comments, reviews, form entries, imported feeds, a CRM pushing in via the REST API. If yes, a full push is off the table. Not risky — off the table.
Is the clone fresh? A clone taken this morning and pushed this afternoon loses one morning. A clone from last month loses a month. The question is not whether the push is safe in principle, it is how much live-only data sits in the gap — and that number only grows while you work.
Do you have a restore you have actually tested? An export sitting on the server is not a restore. Export the live database, import it somewhere else, and open the site. Then you know. Choosing between a backup plugin, a host snapshot and a backup service covers picking something that holds up under this kind of pressure.
Can you take the site offline for ten minutes? Not for the import itself, which is fast, but to stop new writes landing between your export and your import. A push with traffic still flowing loses the orders placed during the window, and those are the hardest to notice later.
Are the two environments the same shape? Same table prefix, same PHP version, same core version, same MySQL or MariaDB major version, same character set and collation. A utf8mb3 staging database imported over a utf8mb4 live one will mangle emoji and non-Latin text quietly.
Do you know every row that is hostname-specific? siteurl and home are the obvious two. The ones that bite are plugin license activations, webhook endpoints registered with a payment gateway, OAuth redirect URIs, SMTP settings and anything with a staging basic-auth credential in it.

The brochure-site case: a full push in a maintenance window

If you answered yes to all six — a marketing site with no logins, no comments, no commerce, where you are the only author — a full push is the right call and far less error-prone than reproducing a redesign by hand. Here is the sequence, using WP-CLI aliases so you never have to remember which shell you are in.

Put this in wp-cli.yml in a directory you work from, or in ~/.wp-cli/config.yml:

@production:
  ssh: [email protected]
  path: /srv/www/example.com/public

@staging:
  ssh: [email protected]
  path: /srv/www/staging/public

An alias accepts user, url, path, ssh and http, and the SSH-only proxyjump and key. You can group aliases into a list and run one command against both. From then on every command is prefixed, which removes an entire class of mistake:

# 1. A restore point you can name, on live
wp @production db export live-before-push-$(date +%F-%H%M).sql

# 2. Close the doors. Any maintenance-mode method is fine
wp @production option update blog_public 0   # optional: while you work

# 3. Export staging with live URLs already substituted.
#    --export writes a transformed dump and leaves staging untouched.
wp @staging search-replace 'https://staging.example.com' 'https://example.com' \
  --all-tables-with-prefix --precise --skip-columns=guid \
  --report-changed-only --export=push-to-live.sql

# 4. Dry-run first if you want to see the scale of the change
wp @staging search-replace 'https://staging.example.com' 'https://example.com' \
  --all-tables-with-prefix --precise --dry-run

# 5. Import on live, then clear everything derived
wp @production db import push-to-live.sql
wp @production rewrite flush
wp @production transient delete --all
wp @production cache flush

Three details in there matter more than they look. --all-tables-with-prefix reaches tables that share your prefix but are not registered on $wpdb, which is where plugin tables live — without it, a plugin storing absolute URLs keeps pointing at staging. --precise forces PHP rather than SQL for every column, which is slower but handles awkward serialised data reliably; WP-CLI already switches to PHP automatically for columns it detects as serialised, and the command does not change primary key values. And --skip-columns=guid leaves post GUIDs alone, because feed readers treat them as identifiers rather than URLs.

What the import does not do

wp db import replays a dump over the existing database. Tables that exist on live but are absent from the dump are left exactly where they are — so a plugin you removed on staging leaves its tables behind on live, and a plugin you added on staging brings its tables with it. Compare the two lists first with wp @production db tables --all-tables-with-prefix --format=csv against the same command on staging.

If URLs come out wrong afterwards, resist the urge to re-run search-replace with different guesses. Check the two stored values first; changing your WordPress site URL safely covers what home and siteurl each control and what happens when WP_HOME or WP_SITEURL in wp-config.php overrides them.

Anything transactional: push files, replay the database

This is the default for stores, membership sites, anything with a form that stores entries. The principle: code is pushed, configuration is replayed, content is moved deliberately.

Step 1: push the files, and only the files

# Themes and plugins up; uploads and config explicitly excluded
rsync -az --delete \
  --exclude 'uploads/' \
  --exclude 'cache/' \
  --exclude 'upgrade/' \
  --exclude 'wp-config.php' \
  --exclude '.env' \
  -e ssh \
  /srv/www/staging/public/wp-content/ \
  [email protected]:/srv/www/example.com/public/wp-content/

Run it with -n first and read the output. --delete is what makes the push idempotent rather than cumulative, and it is also what will remove a file someone uploaded straight into a theme folder on live — which is exactly the practice this workflow is meant to end. If your deploys go through git, the equivalent is a checkout plus wp @production plugin install for anything not vendored.

Keep core identical across both environments while you are at it. A redesign tested on one core version and deployed onto another is a variable you added for no reason, and with security releases like WordPress 7.1.4 landing on 28 October 2026 the gap opens on its own if you let it.

Step 2: find out what actually differs in the database

Most people replay settings from memory and miss two. Diff them instead. Aliases make this a three-line job:

# Plugin and version drift between the two environments
wp @production plugin list --fields=name,status,version --format=csv > live-plugins.csv
wp @staging    plugin list --fields=name,status,version --format=csv > stage-plugins.csv
diff live-plugins.csv stage-plugins.csv

# Settings drift for one plugin's option rows
wp @production option list --search='woocommerce_*' --fields=option_name,option_value --format=csv > live-woo.csv
wp @staging    option list --search='woocommerce_*' --fields=option_name,option_value --format=csv > stage-woo.csv
diff live-woo.csv stage-woo.csv

The wildcard in --search accepts * and ?, so adjust the pattern per plugin prefix. That diff is your change list. Write it down, then apply each line to live through the admin UI or with wp @production option update — and skip the rows you now recognise as environment-specific rather than as settings.

Step 3: move self-contained configuration tables, not the whole database

Some configuration is too fiddly to retype — a rebuilt shipping matrix, for instance. Those live in their own tables and can be moved on their own:

# See every table carrying your prefix before you choose
wp @staging db tables --all-tables-with-prefix --format=csv

# Export just the shipping configuration
wp @staging db export shipping-config.sql \
  --tables=wp_woocommerce_shipping_zones,wp_woocommerce_shipping_zone_locations,wp_woocommerce_shipping_zone_methods

# Or export everything except the tables you must never move
wp @staging db export content-only.sql \
  --exclude_tables=wp_wc_orders,wp_wc_orders_meta,wp_wc_order_addresses,wp_wc_order_operational_data,wp_woocommerce_order_items,wp_woocommerce_order_itemmeta,wp_users,wp_usermeta,wp_comments,wp_commentmeta,wp_woocommerce_sessions,wp_actionscheduler_actions

Be honest about the second command: an exclude list is a list of things you remembered. It is safer than a full push and still not safe, because wp_posts is in there and on a legacy-storage store that is where orders live. If your store is still on legacy order storage, that detail alone is a reason to read what the WooCommerce HPOS migration actually changes before you start moving tables around, so you at least know which tables hold your orders.

Two caveats on the shipping and tax tables specifically, since they are the ones people move most often. Historical orders record the shipping method they used, so recreating zones hands out new instance IDs and old orders can end up referring to a method that no longer exists — harmless for the money, confusing in reports. Tax rates are referenced by ID from the analytics tax lookup table, so replacing wp_woocommerce_tax_rates wholesale can leave historical tax reporting pointing at rows that changed meaning. Move them if you need to, after your accounting period closes rather than mid-quarter.

Step 4: move design work that lives in the database

This is the step that catches people on block themes, and no amount of file pushing solves it. Once you edit a template in the Site Editor, WordPress stores your version in the database as a wp_template or wp_template_part post — the theme file is no longer what renders. Synced patterns are wp_block posts, global style changes are a wp_global_styles post, and block-based menus are wp_navigation posts. All of it is content, not code.

So: either redo the Site Editor work on live (fast, if it is a handful of templates), or export those specific posts and import them. Doing the design work in theme files instead of the editor avoids the problem entirely, which is one of the arguments in customising a block theme with theme.json or a child theme. If you are choosing where to put reusable chunks, synced patterns versus template parts is really a question about which of them you are prepared to move by hand later.

Moving content without moving orders

If what you built on staging is content — twenty new posts, a restructured set of pages — the WordPress exporter under Tools → Export plus the importer on live is the dull, safe route. It writes WXR, an XML format, and the importer creates new rows rather than overwriting existing ones.

Know its limits before you rely on it. Post IDs are assigned fresh on import, so anything referencing a staging post ID — a hard-coded link, a plugin setting pointing at page 412, a block attribute storing an ID — still points at the staging number. Custom tables are not exported at all, so form entries and plugin data do not travel. Attachments are re-downloaded over HTTP from the staging URL, which fails silently if staging is behind basic auth or already gone. And reused term and menu relationships need checking rather than assuming.

Verify the push before you call it done

Checking the homepage proves almost nothing. The failures from a push cluster in places you cannot see from the front page.

Place a real order and refund it. End to end: checkout, payment, confirmation email, admin order screen, invoice, refund. This exercises more of your stack than any other single action on a store.
Count your newest records. Open the order list, the users list and the comments queue, and confirm the most recent entries are from today rather than from the day you cloned. This is the five-second check that catches a destructive push while the backup is still fresh.
Confirm the queue is moving. Under WooCommerce → Status → Scheduled Actions, check that pending actions are being claimed and completed. A push that replaced wp_options can leave the cron array empty, and a stalled Action Scheduler queue looks like nothing at all for about a day.
Grep the database for the staging hostname. wp @production search-replace 'staging.example.com' 'staging.example.com' --all-tables-with-prefix --dry-run reports every remaining occurrence without changing anything. Zero is the answer you want.
Check that staging is not indexable and live is. If you set blog_public to 0 during the window, set it back. A staging copy left crawlable is its own SEO problem, and a live site left noindexed is a worse one.
Purge every cache layer, then look again. Page cache, object cache, CDN. A store with a persistent object cache can serve pre-push option values for hours after the import, which sends you chasing a bug that no longer exists. What breaks and what works with WooCommerce caching covers the exclusions that matter here.
Read Site Health, then the error log. Tools → Site Health flags a failed loopback, a missing extension or an HTTPS mismatch straight away; finding and fixing common problems with Site Health explains which warnings to act on. Then work the general post-change testing checklist for the front end, forms and editor.

Should you push staging to live?

  • Brochure site, you are the only writer: yes, push the whole thing. Export live first, take a short window, search-replace on the way out, flush everything afterwards.
  • Blog with comments and contributors: push files, move posts through the exporter, replay settings from a diff. A full push costs you comments nobody will notice are gone.
  • Any store taking orders: never a full database push. Files up, settings replayed, self-contained config tables moved individually, orders and customers untouched.
  • Membership or LMS site: same as a store. Progress, enrolments and subscription state are live-only data even though they look like content.
  • Big redesign on a transactional site: do the design work in theme files and theme.json rather than the Site Editor, so the push stays a file push. This is the single decision that makes the whole problem smaller.
  • No SSH, no WP-CLI, no tested restore: fix one of those before you push anything. A one-click push with no way to verify and no way back is not a deployment, it is a coin toss with your order history.

The habit worth building is smaller than any of this: write down what you changed on staging as you change it. Nearly every disaster in this area starts with someone reaching for a full database push because they could not remember what they had done, and ends with them restoring a backup to find out.

Frequently asked questions

Can I push a staging site to live without losing new orders?

Yes, but not by pushing the database. Push wp-content files with rsync or git, replay settings changes using a diff of wp option list between the two environments, and move self-contained configuration tables individually with wp db export --tables=. Leave the order, user, comment and session tables alone and your orders are never at risk.

Does WP STAGING or WP Migrate let me push for free?

No. WP STAGING’s free version clones production into a subdirectory; pushing the clone back to production is a Pro feature. WP Migrate Lite does SQL export, full-site ZIP export and find-and-replace, including WP-CLI export and find-replace commands, but push and pull between two installs are Pro. Both free tiers stop at the non-destructive half of the job.

Why not just exclude the order tables from the push?

Because an exclude list only contains what you thought of. Miss wp_users and new customers cannot log in; miss wp_actionscheduler_actions and queued renewals vanish or re-run; and on a store still using legacy order storage, orders are in wp_posts, which almost nobody excludes. Excluding tables is a reasonable tactic for a known, narrow set of tables you want to move. It is not a safe way to move everything else.

Do I need –precise on wp search-replace?

Usually not, and it is cheap insurance. WP-CLI handles PHP serialised data and already switches from SQL to PHP automatically for columns it detects as serialised. --precise forces PHP for every column, which is slower on a large database but removes the detection step as a variable. On a one-off domain change, use it. In a script that runs nightly on a big database, measure first.

My staging URLs are still in the database after the push. What did I miss?

Most often the --all-tables-with-prefix flag, which is what reaches tables sharing your prefix that are not registered on $wpdb — plugin tables, in other words. Run the same replacement as a --dry-run to see where the remaining occurrences are. Also check for protocol-relative (//staging.example.com) and escaped (https:\/\/staging.example.com) forms inside JSON-encoded option values, which a plain replacement will not match.

Should staging be a subdirectory of my live site?

It is the easiest thing to set up and the most misleading environment to test in, because it shares the server, the PHP version, the opcache and often the database. For catching layout and content problems it is fine. For anything where the environment is the variable — a PHP upgrade, a caching change, a database migration — use a separate environment that matches live, and keep it out of search engines either way.

How do I move Site Editor template changes from staging to live?

They are database rows, not files. Edited templates are saved as wp_template and wp_template_part posts, synced patterns as wp_block posts, global styles as a wp_global_styles post. Either redo the edits on live, or export and import those specific posts. If you want template changes to travel with a file push instead, make them in the theme’s own files and theme.json rather than in the editor.

Sources

Leave a Reply to This Post