Skip to content
Mail Cabinet Choose a plan

WordPress Staging Site: How to Create, Hide and Push Changes

WordPress Staging Site: How to Create, Hide and Push Changes

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.xml returns 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.

More on this topic

All articles
Bandwidth Throttling on Hosting: How to Detect, Measure and Avoid It
Hosting Guides and Tutorials07.08.2026

Bandwidth Throttling on Hosting: How to Detect, Measure and Avoid It

How to tell whether your host throttles bandwidth, how to measure it yourself with curl and which measures really reduce your site's…

SSH Keys: How to Create Them, Pick a Type and Disable Passwords
Hosting Guides and Tutorials27.07.2026

SSH Keys: How to Create Them, Pick a Type and Disable Passwords

How to create an SSH key, why Ed25519 beats RSA for new keys, how to add it to a server, a hosting…

IPv6 for a Website: What It Gives, What Breaks and a Checklist
Domains and DNS04.07.2026

IPv6 for a Website: What It Gives, What Breaks and a Checklist

What IPv6 gives a website and what breaks when the AAAA record exists but the server is not ready: listen, ip6tables, certificate,…