A WordPress staging site is a full copy of your site on a separate subdomain, such as staging.example.com, with its own database, where you can update plugins and themes without risking the live site. You build it in five steps: copy the files, copy the database, replace the URLs with wp search-replace (not sed), lock the copy with a password and noindex, and turn off its email and cron. Changes go back to the live site as files, not as a database: on our test bench, overwriting the live database with the staging one wiped 7 new orders and 5 comments and left the live site set to noindex.
How we tested
On 10 November 2026 on a test bench: Ubuntu 24.04 container, nginx 1.24 in front of Apache 2.4, PHP-FPM 8.3, MariaDB 10.11, WordPress 7.1.4, WooCommerce 11.2.2 and WP-CLI 2.12. The “live” site had 25 products from the WooCommerce sample data, 300 orders, 41 comments and three 5 MB photos. We created the staging copy on a subdomain of the same server, and a local mail catcher captured every message instead of sending it. Times and sizes were measured on the bench’s local disk; they are not RichHost server figures. The WP-CLI and Google documentation was checked the same day.
Staging in six steps: command and check
Run the wp commands over SSH inside the site folder. Paths are examples for a layout where a site lives in ~/web/domain/public_html; check yours in the panel.
| Step | Command | How to check |
|---|---|---|
| 1. Subdomain and empty database | Control panel: add the subdomain as a new domain, then create a database | The subdomain opens, the new database has no tables |
| 2. Copy the files | rsync -a from the live site folder to the subdomain folder |
find . -type f | wc -l gives the same number in both folders |
| 3. Copy the database with new URLs | wp search-replace with --export, new database details in the copy’s wp-config.php, wp db import |
wp option get siteurl on the copy shows the subdomain |
| 4. Hide it from search engines and strangers | wp option update blog_public 0, password and X-Robots-Tag in .htaccess |
curl -I without a password returns 401 and X-Robots-Tag |
| 5. Turn off cron and email | DISABLE_WP_CRON, WP_ENVIRONMENT_TYPE, a no-mail mu-plugin |
No POST /wp-cron.php in the log, a test order sends no email |
| 6. Push changes back | rsync --dry-run --itemize-changes, themes and plugins only |
Order and comment counts on the live site are unchanged |
Subdomain and file copy
In most control panels a subdomain is added like any other domain, for example staging.example.com. The panel creates a separate folder with public_html and, if the domain’s DNS zone is on the same server, usually adds a record for the subdomain. SSL is enabled in the domain settings the same way as for the main site. Create the database for the copy in the databases section; many panels prefix database and user names with the account name, so note the full names.
You can copy the files in the panel’s file manager, but with thousands of small files it is easier to connect via SSH and run rsync. Before you start, take a fresh backup of the live site: staging does not replace one.
rsync -a ~/web/example.com/public_html/ ~/web/staging.example.com/public_html/ find ~/web/example.com/public_html -type f | wc -l find ~/web/staging.example.com/public_html -type f | wc -l
How long it takes in practice. The test shop was 224 MB and 10,547 files: WordPress core with all bundled themes 136 MB, plugins 55 MB (almost 5,500 files, mostly WooCommerce), media 33 MB. The database was 5.1 MB in MariaDB, 48 tables.
| Operation on the bench | Time | Result |
|---|---|---|
| rsync of files on the same disk | 1.1 s | 224 MB, 10,547 files |
| tar.gz archive of all files | 15.3 s | 104 MB |
| Database dump (wp db export) | 0.33 s | 1.2 MB SQL, 146 KB gzipped |
| wp search-replace exported to a file | 1.1 s | 6 replacements |
| Database import into the copy | 1.9 s | 48 tables |
On one server, copying a small shop takes seconds; archiving is the slowest part. If the site moves to another server, the procedure is the same as when you move WordPress to another host.
Replacing URLs in the database: why sed breaks settings
WordPress stores some settings (widgets, theme and plugin options) as serialized PHP arrays, where every string is preceded by its length, for example s:152:"...". If you replace the URL with plain sed in the dump, the text gets longer but the number stays the same. We tested this on a text widget with two links to the live site:
$ sed 's#http://live.cw14.test:14101#http://staging.live.cw14.test:14101#g' live.sql > staging_sed.sql
$ wp db import staging_sed.sql
...s:4:"text";s:152:"<a href="http://staging.live.cw14.test:14101/shop/">Go to the shop</a> | <img src=...
PHP Warning: unserialize(): Error at offset 235 of 272 bytes
bool(false)
# after one page view:
a:1:{s:12:"_multiwidget";i:1;}
The string was now 168 characters long (8 extra per URL), and PHP could not read it. The site still returned 200 with no error at all; the widget simply vanished. Worse, after the very first page view WordPress overwrote the broken value with an empty one, so the widget content was gone from the staging database for good. That is why this kind of damage goes unnoticed: settings quietly fall back to defaults. The trap is also sneaky because sed works when the new URL has the same length, and the habit sticks.
WP-CLI unpacks serialized data, changes the text and packs it back with the correct length. Run it with --dry-run first: it shows what will change and where, without writing anything.
$ wp search-replace 'http://live.cw14.test:14101' 'http://staging.live.cw14.test:14101' --skip-columns=guid --dry-run --report-changed-only Table Column Replacements Type wp_options option_value 3 PHP wp_posts post_content 2 SQL wp_users user_url 1 SQL Success: 6 replacements to be made.
Three of the six replacements were in serialized options (type PHP), so sed would have broken half of the changes. We skipped the guid column on purpose: it is a post identifier, not a link, and 337 rows there kept the old URL. The most convenient option for staging is to run the replacement on the live site with --export. The live database stays untouched, and the result goes straight into a file you import into the copy.
cd ~/web/example.com/public_html wp search-replace 'https://example.com' 'https://staging.example.com' --skip-columns=guid --export=$HOME/staging.sql cd ~/web/staging.example.com/public_html wp config set DB_NAME user_staging wp config set DB_USER user_staging wp config set DB_PASSWORD 'new-db-password' wp db import $HOME/staging.sql wp option get siteurl rm $HOME/staging.sql
If WP-CLI is not available, use a migration plugin that explicitly supports serialized data. Find and replace in a text editor has the same flaw as sed.
How to keep staging out of Google
The first thing everyone does is tick the box in Reading settings that asks search engines not to index the site (the blog_public option set to 0). We checked exactly what it changes in WordPress 7.1.4:
- HTML pages get
<meta name='robots' content='noindex, nofollow' />; - the Sitemap line disappears from robots.txt, and
wp-sitemap.xmlreturns 404; - there is no
Disallow: /rule in robots.txt and no X-Robots-Tag header; - images from uploads are served with 200 and no marking at all: an HTML meta tag does not cover them.
So the box only asks for pages not to be indexed, while the copy with all its data is open to anyone who knows the address. Blocking everything in robots.txt is not a good idea either: Google states that for a URL blocked by robots.txt the crawler will never see the noindex rule, and the URL can still appear in results through links (Google documentation, checked 10.11.2026). It is safer to put the subdomain behind a password and add an X-Robots-Tag header, which also covers images and PDFs.
If the site is served by Apache (as on our bench, with nginx in front of Apache), add the rules at the top of the copy’s .htaccess file. Keep the password file outside public_html. On an nginx-only server, the same is done with auth_basic directives in the site configuration.
htpasswd -cB ~/web/staging.example.com/.htpasswd tester # top of ~/web/staging.example.com/public_html/.htaccess Header always set X-Robots-Tag "noindex, nofollow" AuthType Basic AuthName "Staging" AuthUserFile /home/user/web/staging.example.com/.htpasswd Require valid-user
Check it the way a search crawler sees the site, without a password and with its User-Agent:
$ curl -I -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" http://staging.live.cw14.test:14101/ HTTP/1.1 401 Unauthorized X-Robots-Tag: noindex, nofollow WWW-Authenticate: Basic realm="Staging"
Without a password both the home page and an image returned 401, and a wrong password also gave 401 (Apache log: AH01617: user tester: authentication failure). With the right password, pages and images returned 200 with the X-Robots-Tag header. The live site was unaffected: its normal robots meta tag and a working sitemap.
Email and cron: the copy must not live its own life
The database copy contains every customer’s address, so staging can email them. We marked test order #35 as completed on the copy, as anyone testing a shop would, and the catcher immediately received Your CW14 Shop order is now complete addressed to the customer. On real hosting that email would have reached a real person.
The simplest protection is to declare the copy a staging environment and block its email with a pre_wp_mail filter in an mu-plugin. On a live site with the production environment type it blocks nothing.
cd ~/web/staging.example.com/public_html
wp config set WP_ENVIRONMENT_TYPE staging
wp config set DISABLE_WP_CRON true --raw
mkdir -p wp-content/mu-plugins
# wp-content/mu-plugins/staging-no-mail.php
<?php
/* Plugin Name: Staging: no outgoing mail */
add_filter('pre_wp_mail', function ($return, $atts) {
if (wp_get_environment_type() === 'production') {
return $return;
}
$to = is_array($atts['to']) ? implode(',', $atts['to']) : $atts['to'];
error_log('STAGING mail blocked: ' . $to . ' | ' . $atts['subject']);
return false;
}, 10, 2);
After that, the next test order and the password reset form sent no email at all, and the PHP log showed lines like STAGING mail blocked: customer2.seed@example.com | Your CW14 Shop order is now complete. Plugins that send email through their own API and bypass wp_mail are not stopped by this filter: disable them on the copy or switch them to test keys.
Cron is a similar story. WP-Cron is triggered by visits, and 12 scheduled events moved to the copy with the database, including the WooCommerce background task queue. Before password protection, every staging page view fired POST /wp-cron.php with a 200 response. After we added the password we noticed a side effect: WordPress’s internal request to itself got 401, and an event due immediately just sat in the queue. Cron on the copy silently stopped, so any test of scheduled tasks would give false results. The DISABLE_WP_CRON constant makes this explicit: after setting it there were no requests to wp-cron.php in the log at all, and you can run a task by hand with wp cron event run --due-now. If a server cron job calls WP-Cron on the live site, do not copy that job for the subdomain.
How to push changes back without losing orders
Staging is a snapshot taken at copy time, while the live site keeps running. We reproduced a typical situation. On the copy we installed a small plugin, changed one CSS line in the theme, created a page and, while testing, marked two orders as completed. Meanwhile the live site received 7 new orders and 5 comments. Then we tried two ways of pushing back.
| Metric | Live before push | Staging | Live after database overwrite | Live after file push |
|---|---|---|---|---|
| Orders | 307 | 300 | 300 | 307 |
| Comments | 46 | 41 | 41 | 46 |
| Test “completed” statuses | 0 | 2 | 2 | 0 |
| blog_public | 1 | 0 | 0 (noindex on live) | 1 |
| New plugin | none | active | in the database, files missing, WordPress deactivated it | active |
The database overwrite did three harmful things at once: it erased everything added to the live site after the copy was made, carried over the test order changes, and switched the live site to noindex, because blog_public is stored in the database. We restored the live site from the dump taken before the push and repeated the push the right way, with files only:
$ cat push-exclude.txt wp-config.php .htaccess wp-content/uploads/ wp-content/cache/ wp-content/mu-plugins/staging-no-mail.php wp-content/upgrade/ $ rsync -a --delete --dry-run --itemize-changes --exclude-from=push-exclude.txt staging/ live/ cd+++++++++ wp-content/plugins/cw14-delivery-note/ >f+++++++++ wp-content/plugins/cw14-delivery-note/cw14-delivery-note.php >f.st...... wp-content/themes/twentytwentyone/style.css
The dry run listed exactly the three changes we had made and nothing else. After the real run, all that was left was to activate the plugin on the live site and create the page there again. Orders, comments and search visibility stayed intact. The --delete flag removes files on the live site that are missing from the copy, so never use it without a dry run.
Database changes (pages, plugin settings, menus) are moved by hand: while testing, write down what you changed and repeat it on the live site. It takes longer than a single import, but nothing gets lost.
What to choose: if X, do Y
| If | Do |
|---|---|
| You update plugins, the theme or the PHP version | Test on staging, then repeat the same update on the live site or push the files with rsync after a dry run |
| You changed settings, pages or menus | Repeat the changes on the live site by hand from your notes |
| Shop, forum, site with user registrations | Never overwrite the live database with the staging one |
| Brochure site with no forms or comments, lots of changes | Replacing the database is possible: backup, maintenance mode, wp search-replace back and blog_public = 1 |
| No SSH or WP-CLI | A migration plugin that supports serialized data; sed and text editors will not do |
| Testing is finished | Delete the subdomain and the copy’s database: a forgotten copy with customer data is a risk |
Staging does not need a separate server: a subdomain and a second database on the same account are enough if your plan allows several sites. On RichHost hosting plans the number of sites is unlimited, with our own control panel with an AI assistant and free Let’s Encrypt SSL.
FAQ
Is the “discourage search engines” box in WordPress enough?
No. On our bench it only added a noindex meta tag to HTML pages. Images were left unmarked, and the copy itself was open to anyone. A password on the subdomain plus an X-Robots-Tag header covers everything.
How often should I refresh staging?
Make a fresh copy from the live site before every serious test. An old staging copy lags behind in plugin versions and data, so a test can pass there and fail on the live site.
Why did my site drop out of Google after a push?
The most common cause is that blog_public = 0 moved to the live site together with the database. Check with wp option get blog_public (it should be 1) and search the home page source for noindex.
Can I test payments on staging?
Only in the payment provider’s test mode with test keys. Replace the live keys in the database copy right after you create staging, along with keys for mailing services.



