RE:NODE

WordPress staging site: a safe update routine with backups

By RE:NODE Team
18 min read


The safe way to update WordPress is a fixed order: take a backup of the files and the database, restore that backup onto a second copy of the site, run the updates there, click through the pages that earn you money, and only then repeat the same updates on the live site. A fifth step, which most guides skip, is to prove the backup works by restoring it somewhere. If you use the staging copy as the restore target, steps two and five are the same action, and the routine costs you roughly twenty minutes a month. The rest of this post is that routine in detail, with the commands, the wp-config.php constants that stop a copy from behaving like the original, and the failures that make a "test" worthless.

Why updates break WordPress sites#

A WordPress site is core, a theme and a pile of plugins written by different people, running on one PHP version against one database. An update changes one of those pieces without knowing what the others do. Most updates are fine. The ones that hurt fall into four groups:

  • A plugin calls something the new core removed or deprecated, and the page that used it shows a fatal error or a blank screen. The symptoms and the log lines are covered in WordPress white screen and 500 errors.
  • A plugin update changes its database tables on first load. That is a one-way change: putting the old plugin files back does not put the old table layout back.
  • A theme update overwrites your edits. Anything you changed inside a parent theme's folder is gone, and nothing warns you first. A child theme exists to prevent exactly this.
  • A PHP version change, which is not a WordPress update at all but often travels with one. A plugin that ran on 8.2 may log warnings or fail on 8.4.

You cannot predict which group an update belongs to by reading its changelog. You find out by running it somewhere that does not matter. That is the whole case for staging, and it is also why "I'll just update on a quiet night" is not a plan: quiet nights do not make a broken checkout page any less broken, they only mean fewer people notice before you do.

The routine in six steps#

This is the order. Each step has a section further down.

  1. Check what is pending. List core, plugin and theme updates. Read the changelog of anything that jumps a major version.
  2. Back up production: all files, plus a separate database dump.
  3. Refresh staging from that backup. Restore the files, import the database, rewrite the URLs, switch off the things a copy must not do.
  4. Update staging and test it against a written list of pages and actions.
  5. Update production, in the same order, in a quiet hour, with the backup from step 2 still in hand.
  6. Check production and keep a note of what changed and when.
before anythingrestoreupdate here firstonly if it passesLive sitefiles + databaseBackupfiles and SQL dumpStaging copyrestored from backupTest passyour written listUpdate livesame order as staging
The update routine, top to bottom

The diagram shows one detail worth stating: the staging copy is built from the backup, not from the live site directly. That is deliberate. It means every run of the routine exercises your restore path, and if the restore fails you learn it on a Tuesday with nothing at stake rather than during an outage.

Step 1: see what is pending#

You can read the list in wp-admin under Dashboard, then Updates. If you have a shell where WordPress lives, WP-CLI gives the same information in a form you can paste into a note:

bash
$ wp core check-update$ wp plugin list --update=available --fields=name,version,update_version$ wp theme list --update=available --fields=name,version,update_version

Two rules for reading that list. First, a plugin that jumps a major version (for example 4.x to 5.x) gets its changelog read in full, because that is where the authors announce removed features and database migrations. Second, a plugin that has not been updated by its author in two years deserves a decision now, not at update time: replace it, or accept that it is a risk. Hardening WordPress explains why abandoned plugins are the usual way sites get compromised.

Also write down the PHP version in use. In wp-admin it is under Tools, then Site Health, then Info, then Server. Staging must run the same one, or the test says nothing.

Step 2: back up files and database#

A WordPress backup is two things, and a copy of only one is not a backup. The files hold wp-content (themes, plugins, uploads) and wp-config.php. The database holds every post, page, comment, user and setting. Restoring the files without the database gives you a site with its theme and no content; restoring the database without the files gives you content pointing at missing images.

Take the file backup first, then dump the database straight after it. On RE:NODE web plans a backup is a button, or a task on the Schedules tab, and the slots are included with every plan; the backup covers the server's files, so the database dump is a separate step you do yourself. If you have a shell, mysqldump through WP-CLI is the shortest route:

bash
$ wp db export - --single-transaction | gzip > ~/pre-update-$(date +%F).sql.gz$ gzip -t ~/pre-update-$(date +%F).sql.gz && echo "archive readable"

Without WP-CLI, the same job with mysqldump and the credentials from wp-config.php:

bash
$ mysqldump --single-transaction --no-tablespaces \    -h DB_HOST -u DB_USER -p DB_NAME | gzip > pre-update.sql.gz

The --single-transaction flag takes a consistent snapshot of InnoDB tables without locking the site. --no-tablespaces avoids a permissions error on hosts where the database user lacks the PROCESS privilege, which is the usual case for a user that can only touch its own database. mysqldump backup and restore covers the flags in full.

On a plan with the panel's database page, there is a second route with no shell: Open in phpMyAdmin signs you in with a one-use token, and Export, then Custom, then gzip, produces the same file. The phpMyAdmin import and export guide covers the size limits that bite on large databases.

Then download both files off the machine. A backup that sits beside the thing it protects is a convenience, not a safeguard. The panel's backups can be downloaded; so can the dump, over SFTP or the file manager (SFTP and the file manager). If you lock the backup in the panel, it also survives the rotation that deletes the oldest slot when a new one is taken. Do that for the pre-update backup, and unlock it once the update has been fine for a week.

Step 3: build the staging copy#

Staging is a second WordPress install that holds a recent copy of production. It can be a subdomain on the same account, a second small plan, or a folder on a machine you run yourself. The requirements are few:

  • The same PHP version as production, and a similar memory limit. A staging site on PHP 8.4 that tests a production site on 8.2 proves nothing about the update.
  • A database of its own. Never point staging at the live database, even "just to look". A plugin updating its tables on staging would then change the live site.
  • Its own address, such as staging.example.com, with HTTPS. On RE:NODE app and web plans include a proxy slot: point an A record at the address shown and the certificate is issued and renewed for you. A second WordPress server is the cleanest staging copy, and WordPress plans start from $5 a month at the time of writing - see web hosting for the current ladder. The general case for keeping two environments, and what to make different on purpose, is in staging and production on one account.

Now restore into it. The steps are the same as a migration, which is migrating WordPress to a new host in more detail; here is the short version.

  1. Unpack the file backup into the staging web root.
  2. Edit the staging wp-config.php so DB_NAME, DB_USER, DB_PASSWORD and DB_HOST are the staging database's values. Do not copy production's.
  3. Import the dump.
  4. Rewrite the URLs.
bash
$ gunzip -c pre-update-2026-10-20.sql.gz | wp db import -$ wp search-replace 'https://example.com' 'https://staging.example.com' \    --skip-columns=guid --all-tables --dry-run$ wp search-replace 'https://example.com' 'https://staging.example.com' \    --skip-columns=guid --all-tables

Always run the --dry-run first and read the count of replacements per table. wp search-replace understands PHP serialised data, which is why it exists: a plain SQL REPLACE() on a serialised option changes the length of a string without updating the length prefix stored beside it, and WordPress then treats the whole option as corrupt. --skip-columns=guid leaves post GUIDs alone, because they are identifiers rather than links and are not supposed to change when a site moves.

If you have no shell, the Better Search Replace plugin does the same job from wp-admin, and the same serialised-data rule applies to it. Never use a text editor's find-and-replace on the dump.

Make the copy behave like a copy

A staging site that behaves like the original is dangerous. It sends the original's emails, charges the original's payment gateway, publishes to the original's social accounts, and shows up in search results as a duplicate of your real site. Put these in the staging wp-config.php, above the line that says to stop editing:

wp-config.php (staging only)
define( 'WP_ENVIRONMENT_TYPE', 'staging' );define( 'DISABLE_WP_CRON', true );define( 'WP_DEBUG', true );define( 'WP_DEBUG_LOG', true );define( 'WP_DEBUG_DISPLAY', false );define( 'AUTOMATIC_UPDATER_DISABLED', true );

What each one does:

ConstantWhy it is set
WP_ENVIRONMENT_TYPELets plugins and your own code detect staging with wp_get_environment_type()
DISABLE_WP_CRONStops scheduled jobs (sends, syncs, publishing) from firing on the copy
WP_DEBUG and WP_DEBUG_LOGWrites PHP notices to wp-content/debug.log so you can read what the update did
WP_DEBUG_DISPLAYKeeps those notices out of the page itself
AUTOMATIC_UPDATER_DISABLEDStops staging updating itself between your test runs

Debug logging belongs on staging only. On production, a world-readable debug.log is an information leak. The cron constant is explained in WP-Cron vs real cron; on staging you want the opposite of what that post sets up on production.

Then stop outgoing mail and search indexing. Mail first, as a small must-use plugin so it survives plugin changes. Create wp-content/mu-plugins/staging-no-mail.php:

wp-content/mu-plugins/staging-no-mail.php
<?php// Staging only: wp_mail() returns false and nothing is sent.add_filter( 'pre_wp_mail', '__return_false' );

Anything that sends mail some other way, such as a plugin with its own SMTP or API integration, has to be switched off in that plugin's settings. Check payment gateways in particular: put them in test mode, or deactivate the plugin on staging.

Search engines next:

bash
$ wp option update blog_public 0

That sets "Discourage search engines from indexing this site", which adds a noindex signal. It is a request, not a lock, so also keep the staging address out of any public link. If your host offers password protection on a subdomain, add it; a login prompt is the only reliable way to keep both crawlers and curious customers out.

Finally, change the site's appearance so nobody confuses the two. A banner or a different admin colour scheme is enough. Updating the wrong tab at the end of a long day is how a routine like this goes wrong.

Step 4: update staging and test it#

Update in an order that makes the cause of a failure obvious: one thing at a time, and test between them if you can afford to.

bash
$ wp plugin update --all$ wp theme update --all$ wp core update$ wp core update-db

The order is deliberate. wp core update-db runs the database upgrade that follows a major core release, and doing core last means a plugin failure is not confused with a core failure. If you have a lot of plugins, update them in batches of three or four and test between batches; when something breaks, the culprit is in the last batch. If it still breaks with one plugin in the batch, deactivate it and confirm:

bash
$ wp plugin deactivate suspect-plugin$ wp plugin list --status=active --fields=name,version

Test against a list, not a feeling

"I clicked around and it looked fine" tests whatever you remembered to click. Keep a short written list in a note and tick it off on every run. A typical list for a small business site:

CheckWhat failing looks like
Home page and one long post loadWhite screen or missing styles
A page built with the page builderBlocks missing, layout collapsed
Contact form submitsNo confirmation, or an error
Search returns resultsEmpty results or a fatal error
Log in and out as an editorRedirect loop
Add to cart, reach checkout (shops)Cart empty, gateway missing
Upload an image in MediaHTTP error on upload
wp-content/debug.logNew fatal errors or repeated deprecations

Read the log before you decide it passed. A site can look fine while a plugin logs a fatal error on every request that the cache hides. Run the final check with the cache plugin cleared, because cached pages test nothing:

bash
$ wp cache flush$ tail -n 50 wp-content/debug.log

Also run the integrity check, which compares core files against the official checksums:

bash
$ wp core verify-checksums$ wp db check

A failed checksum on a file you did not edit means something modified core. That is worth knowing before you put the same update on the live site.

If the test fails, you have learned what you came for. Note which plugin or theme caused it, decide whether to wait for a fixed release, replace the plugin, or pin that one plugin at its current version, and run the live update without it. The live site has not been touched.

Step 5: update production#

Only now. Take a fresh backup and database dump, because the one from step 2 is already hours or days old and posts, orders and comments have arrived since. Then:

  1. Put up maintenance mode if the site takes orders or form submissions. wp maintenance-mode activate does it with WP-CLI; the activate and deactivate pair is the whole interface.
  2. Run exactly the update commands you ran on staging, in the same order.
  3. Clear caches: the cache plugin, the object cache if you run one, and any CDN.
  4. Walk through the same test list, on the live address.
  5. Deactivate maintenance mode.

Do it at the quietest hour your analytics shows, and not before you go to bed. Updating at eleven at night and discovering the checkout is broken at seven in the morning loses a night of orders; updating at two in the afternoon on a quiet weekday and finding out at two-twenty loses minutes. "Quiet" matters less than "I am here, and I can roll back".

Rolling back when it still goes wrong

Staging catches most problems, not all, because staging does not have production's traffic, caches or current data. If production breaks anyway, you have three levers, in order of cost:

  1. Deactivate the culprit. If the site shows a white screen and you cannot reach wp-admin, rename the plugin's folder under wp-content/plugins over SFTP or in the file manager, or run wp plugin deactivate name. This is usually enough.
  2. Reinstall the previous version of that plugin, if the update did not change the database. wp plugin install name --version=1.2.3 --force installs a given release. If the plugin migrated its tables, the older version may not understand them.
  3. Restore the backup from step 5, both files and database, which loses everything written since. For a shop that is a real cost, which is why the maintenance-mode step exists.

The point of having rehearsed on staging is that rollback should almost never be the third lever.

Step 6: prove the restore works#

A backup nobody has restored is a hypothesis. Everything above assumes that when you import pre-update.sql.gz it produces a working site. You find out whether it does only by importing it, and the staging refresh in step 3 is exactly that test, as long as you treat a problem there as a finding rather than a nuisance.

Make the check explicit instead of implicit. After each restore, compare the staging copy to what you know about production:

bash
$ wp db query "SELECT COUNT(*) FROM wp_posts WHERE post_status='publish';"$ wp db query "SELECT COUNT(*) FROM wp_users;"$ wp post list --post_type=page --post_status=publish --format=count

Run the same query on production and the numbers should match, to within what was published between the backup and the check. Table prefixes differ between sites; wp db prefix prints yours. Then open the newest post and the newest upload, because a restore with the right counts and no images is still a failed restore.

Three checks catch most bad backups:

  • The archive is readable: gzip -t file.sql.gz returns nothing when it is intact. A truncated download or full disk shows up here.
  • The file is a plausible size. If the dump is a tenth of last month's, something failed silently, and a backup job that has been writing 2 KB files for six weeks is more common than it should be.
  • The restore ends without errors. A dump that stops halfway through a CREATE TABLE imports "successfully" if you pipe errors to nowhere. Read the output.

Once a quarter, go one step further and restore into a completely empty database and an empty folder, using only your backup files and your notes. That is the version of the test that matches a real disaster, where you do not have the old install to borrow settings from. Testing a restore before you need it and backups that actually restore go into the reasoning.

On RE:NODE, restoring a panel backup is one button on the Backups tab, so the file half of this check is quick; the database half is the import you do yourself, which is why the dump belongs in the routine rather than in memory.

Automating the boring parts#

A routine only survives if it is cheap. Three things are worth scripting.

A scheduled backup of the files. The Schedules tab runs backups on a cron expression, so a daily backup in the small hours needs no script of your own. Add a second schedule with a few minutes' delay for the dump if you can run it from the same place. Cron expressions explained covers the syntax.

A scheduled database dump when you have a shell:

bash
0 3 * * * cd /var/www/example.com && /usr/local/bin/wp db export - --single-transaction | gzip > /var/backups/wp-$(date +\%F).sql.gz

The backslash before % matters in a crontab; an unescaped % is treated as a newline. Prune old ones with find /var/backups -name 'wp-*.sql.gz' -mtime +14 -delete.

A staging refresh script that does steps 3 in one go, so refreshing is a command rather than twenty minutes of clicking. Its shape is: unpack files, import the dump, run search-replace, write the staging constants. Keep it in a note or in a private repository with no credentials in it; credentials come from the environment. The first time you write it you will find the one manual step you had forgotten, which is the best argument for scripting it.

Automatic background updates deserve a decision too. WordPress applies minor core updates by default. Plugin and theme auto-updates can be switched per item in wp-admin. A reasonable policy is: minor core on, and for plugins, auto-update the ones you can afford to see break and update the rest through the routine. To stop all of it on a site you update by hand:

wp-config.php
define( 'WP_AUTO_UPDATE_CORE', 'minor' );add_filter( 'auto_update_plugin', '__return_false' );

The first line is the default behaviour written down; the filter (placed in a small must-use plugin, not in wp-config.php, which is loaded before filters can be added) turns off plugin auto-updates. Do not turn off security fixes everywhere and then forget the site.

Mistakes that make staging useless#

A staging site fails in a handful of predictable ways, and all of them end with a green test and a broken live site.

  • It has drifted. Staging was built in March and not refreshed since. The live site has twelve new plugins and a different theme. Rebuild staging from a fresh backup every time, which is why the routine does.
  • It runs a different PHP version or memory limit, so the update succeeds where live would not.
  • It has no real data. A staging site with three test posts will never show the plugin that chokes on a 40,000-row table. Restore the real database.
  • It sends real email or takes real payments. See the constants above.
  • Someone edits it directly, and the fix that "worked on staging" is a hand-edited file that never reaches production. Changes go through the same route on both.
  • It is the live database. The cheapest mistake, the most expensive result. Check DB_NAME before you do anything.

None of them is exotic. They happen because staging feels optional, and it is optional only until the first update that breaks the live site.

FAQ#

Do I need a staging site for a small blog?

Not always. A blog with a free theme, four plugins and no shop can reasonably take the backup, update live and check, because the worst realistic outcome is a short outage you can roll back with the backup. Staging earns its keep as soon as the site takes money, collects leads, or has a page builder and custom code.

Can I use a staging plugin instead of a second install?

Staging plugins can clone a site to a subfolder or a subdomain and push changes back. They work, but they have the same hazards as a manual copy (shared uploads, stray mail, a database prefix clash) and a push-back step can overwrite live content created since the copy. A separate install built from a backup is simpler to reason about, and it tests your restore at the same time.

How often should I update?

Check weekly and apply security releases within a day or two. Run the full routine for anything else at a fixed point, for example the first working day of the month. Letting updates pile up makes each batch bigger and the culprit harder to find.

Should staging use the same database server as production?

Use a separate database, on the same engine and major version. The same server is acceptable if your plan gives you more than one database; the isolation that matters is separate credentials and a separate schema, so a mistake on staging cannot touch live data.

What if the update breaks the site and I cannot open wp-admin?

Rename the wp-content/plugins/name folder of the plugin you just updated, or the whole plugins folder, over SFTP or in the file manager. WordPress then deactivates what it cannot find and the admin usually loads. Read wp-content/debug.log to confirm which plugin it was before you put anything back.

Is restoring a backup the same as a rollback?

No. A rollback after a bad update should put back only the plugin or theme that failed. A restore puts the whole site back to the backup moment, and everything written since is lost. Treat the restore as the last resort and the routine above as the reason you rarely need it.

Was this helpful?