How to make a staging copy of a WordPress site on the same server

A staging copy is a private twin of your site at its own address. Try updates, new plugins and design changes there first. If something breaks, only the copy breaks.

Never copy the staging database back over the live one. Everything that happened on live since you made the copy would be lost: new orders, comments, sign-ups and form entries. Move your changes back the safe way (see "Moving changes back to live").

Before you start

  • The examples use a live site called shop.example. It runs as the Linux user shop, WordPress is in /var/www/shop.example/public, and the database is shop. The copy will live at staging.shop.example. Use your own names.
  • Take a backup of the live site first. See our backup guide.
  • Check the free space. The copy needs as much disk space as the site and its database. sudo du -sh /var/www/shop.example shows the files, df -h /var/www what's free.
  • Add a DNS record for staging.shop.example that points to the same server as the live site.
  • You need WP-CLI. Run wp commands as the site's own user, never as root.
  • The PHP version in paths is 8.3 on Ubuntu 24.04 and 8.4 on Debian 13. Check yours with php -v.
  • We tested every step on Ubuntu 24.04 (PHP 8.3, MariaDB 10.11) and Debian 13 (PHP 8.4, MariaDB 11.8) with WordPress 7.1 and WP-CLI 2.12.

1. Give staging its own user, folder and PHP-FPM pool

The copy gets its own Linux user, just like a separate site. If a plugin you try on staging turns out to be bad, it can't touch the live files. Our guide to one PHP-FPM pool per site explains each part. Here are the same steps with the staging names:

sudo useradd --system --user-group --create-home \
  --home-dir /var/www/staging.shop.example --shell /usr/sbin/nologin shop-staging
sudo mkdir -p /var/www/staging.shop.example/{public,sessions,tmp}
sudo chown -R shop-staging:shop-staging /var/www/staging.shop.example
sudo chmod 750 /var/www/staging.shop.example
sudo chmod 700 /var/www/staging.shop.example/{sessions,tmp}
sudo usermod -aG shop-staging www-data

For the pool, copy the live site's pool file and change the names in it:

sed -e 's/^\[shop\]/[shop-staging]/' \
    -e 's/^user = shop$/user = shop-staging/' \
    -e 's/^group = shop$/group = shop-staging/' \
    -e 's|/run/php/shop.sock|/run/php/shop-staging.sock|' \
    -e 's|/var/www/shop.example|/var/www/staging.shop.example|g' \
    /etc/php/8.3/fpm/pool.d/shop.conf | sudo tee /etc/php/8.3/fpm/pool.d/shop-staging.conf

Read the new file once. The pool name, user, group, the socket and every folder must say staging. Then:

sudo php-fpm8.3 -t
sudo systemctl reload php8.3-fpm
ls /run/php/

You should now see shop-staging.sock next to shop.sock.

2. Give staging its own database

Make a long random password and note it, you'll need it in step 5:

openssl rand -hex 20

Create the database and a user that can only use that one database:

sudo mysql -e "CREATE DATABASE shop_staging;
CREATE USER 'shop_staging'@'localhost' IDENTIFIED BY 'the-password-from-above';
GRANT ALL PRIVILEGES ON shop_staging.* TO 'shop_staging'@'localhost';"

On MariaDB, sudo mariadb works too. Never let staging use the live database or the live database user. One wrong click on staging would then change the live site.

3. Set up Nginx, a password and HTTPS

Staging holds a full copy of your site, with your users and maybe customer data. Keep it closed to the public. The simplest way is a password in front of every page (HTTP basic auth). Install the tool that makes the password file and add a user:

sudo apt install apache2-utils
sudo htpasswd -c /etc/nginx/staging.shop.example.htpasswd anna
sudo chown root:www-data /etc/nginx/staging.shop.example.htpasswd
sudo chmod 640 /etc/nginx/staging.shop.example.htpasswd

It asks for the password twice. Add more people later with the same command without -c, because -c starts a new, empty file. Despite the package name, you don't need Apache: it only installs small helper tools.

Create /etc/nginx/sites-available/staging.shop.example:

server {
    listen 80;
    listen [::]:80;
    server_name staging.shop.example;
    root /var/www/staging.shop.example/public;
    index index.php;

    # Ask for a name and password on every page
    auth_basic "Staging";
    auth_basic_user_file /etc/nginx/staging.shop.example.htpasswd;

    # Tell search engines not to list anything here
    add_header X-Robots-Tag "noindex, nofollow" always;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/shop-staging.sock;
    }
}

If the live site's Nginx file has extra rules, such as the ones from our WordPress hardening guide, copy them in too, so staging behaves like live. One catch: if you put an add_header line inside a location block, Nginx drops the X-Robots-Tag line from above for that block. Repeat it there.

Turn it on:

sudo ln -s /etc/nginx/sites-available/staging.shop.example /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Now get a free certificate, as in our HTTPS guide. Do it before you type the password in a browser: without HTTPS, the password travels in plain text.

sudo certbot --nginx -d staging.shop.example

The password doesn't get in the way: in our tests, certbot got the certificate with the password already on. Certbot adds the HTTPS lines to the same file and keeps your auth_basic and X-Robots-Tag lines.

Rather allow only your own IP address? Use these two lines instead of the two auth_basic lines (one allow line per address):

allow 203.0.113.10;
deny all;

That works well with a fixed office address, but not when you work from changing places. The password works everywhere.

4. Copy the files

sudo rsync -a --delete \
  --exclude=/wp-config.php \
  --exclude=/wp-content/mu-plugins/staging-only.php \
  --exclude=/wp-content/cache/ \
  /var/www/shop.example/public/ /var/www/staging.shop.example/public/
sudo cp /var/www/shop.example/public/wp-config.php /var/www/staging.shop.example/public/
sudo chown -R shop-staging:shop-staging /var/www/staging.shop.example/public
  • The slashes at the end of both folders matter. They tell rsync to copy what's inside the folder, not the folder itself.
  • --delete removes files on staging that are gone from live, so the copy matches exactly.
  • The --exclude lines protect staging's own files when you copy again later: its wp-config.php (step 5) and its email blocker (step 8). The cache folder isn't needed.
  • The cp line is for the first copy only. It gives staging a wp-config.php to start from. Don't run it again later, or staging points at the live database.

If your wp-config.php sits one folder above public, copy it to /var/www/staging.shop.example/ instead.

5. Point staging at its own database

Change the copied wp-config.php, as the staging user:

cd /var/www/staging.shop.example/public
sudo -u shop-staging -- wp config set DB_NAME shop_staging
sudo -u shop-staging -- wp config set DB_USER shop_staging
sudo -u shop-staging -- wp config set DB_PASSWORD 'the-password-from-step-2'
sudo -u shop-staging -- wp config set WP_ENVIRONMENT_TYPE staging
sudo -u shop-staging -- wp config set DISABLE_WP_CRON true --raw
sudo -u shop-staging -- wp config shuffle-salts
  • WP_ENVIRONMENT_TYPE tells WordPress and plugins that this is a staging site. Our email blocker in step 8 checks it.
  • DISABLE_WP_CRON stops scheduled tasks from running on their own. More in step 9.
  • shuffle-salts gives staging new secret keys, so a login on one site never counts on the other.

Check the file for other lines that name the live site, and change them too:

grep -n -E "shop.example|WP_HOME|WP_SITEURL|REDIS|CACHE" wp-config.php

WP_HOME and WP_SITEURL must use the staging address. If live uses Redis or Memcached for its object cache, give staging its own key prefix or database number (see your cache plugin's docs). Otherwise both sites share one cache and show each other's data.

6. Copy the database

This exports the live database as the live user and imports it into staging as the staging user, in one go. No dump file is left lying around:

sudo -u shop -- wp --path=/var/www/shop.example/public db export - --single-transaction \
  | sudo -u shop-staging -- wp --path=/var/www/staging.shop.example/public db import -

The - means "write to the screen" for the export and "read from the screen" for the import, so the data flows straight through. --single-transaction makes the export without locking the tables, so live visitors don't have to wait. You should see Success: Imported from 'STDIN'.

The live site doesn't change. In our tests, the live database and files were exactly the same before and after the whole guide.

7. Change the address to the staging address

Right now, the staging database still says https://shop.example everywhere: in the settings, in links inside posts, in theme and widget settings. If you opened staging now, it would send you to the live site.

Use WP-CLI's search-replace for this, never a text editor or sed on a dump. WordPress saves many settings in a packed format (PHP "serialized" data) that also stores the length of each piece of text, for example s:27:"https://shop.example/about/". The staging address is longer, so the length must change too, or WordPress can't read the setting and quietly drops it. WP-CLI unpacks the data, makes the change and packs it again with the right length.

Try it first with --dry-run. It only counts:

cd /var/www/staging.shop.example/public
sudo -u shop-staging -- wp search-replace '//shop.example' '//staging.shop.example' \
  --all-tables-with-prefix --skip-columns=guid --precise --dry-run --report-changed-only

If the list looks right, run it for real, without --dry-run. Then once more for addresses written with backslashes, as some page builders and plugins save them (https:\/\/shop.example):

sudo -u shop-staging -- wp search-replace '//shop.example' '//staging.shop.example' \
  --all-tables-with-prefix --skip-columns=guid --precise --report-changed-only
sudo -u shop-staging -- wp search-replace '\/\/shop.example' '\/\/staging.shop.example' \
  --all-tables-with-prefix --skip-columns=guid --precise --report-changed-only
sudo -u shop-staging -- wp cache flush

What the options mean:

  • Searching for //shop.example instead of https://shop.example catches old http:// links too. If your site also uses www., run it once more for //www.shop.example.
  • --all-tables-with-prefix also changes the tables that plugins add, such as shop or form tables. By default, WP-CLI only changes WordPress's own tables.
  • --skip-columns=guid leaves the guid column alone. It's a fixed ID for each post, not a link, and WordPress says never to change it (see the official sources).
  • --precise handles every column the slow and careful way. Without it, WP-CLI already does this for columns with packed data and uses quick database queries for the rest. It's a little extra safety and costs only time.

Check the result:

sudo -u shop-staging -- wp option get home
sudo -u shop-staging -- wp db search '[^.]shop\.example' --regex --all-tables-with-prefix

The first line should print https://staging.shop.example. The second lists every place that still says shop.example without staging. in front. In our tests, only two kinds were left, and both are fine: the guid column (on purpose) and email addresses such as admin@shop.example.

8. Stop staging from sending real emails

The copy has your real users and customers. A test order, a password reset or a newsletter plugin on staging would email them for real. Block all emails on staging with a small must-use plugin. Must-use plugins are always on and can't be turned off from the dashboard.

sudo -u shop-staging mkdir -p wp-content/mu-plugins
sudo -u shop-staging nano wp-content/mu-plugins/staging-only.php
<?php
/*
 * Plugin Name: Staging only: no real emails
 * Description: On the staging copy, write emails to the PHP error log instead of sending them.
 */
if ( 'staging' === wp_get_environment_type() ) {
    add_filter( 'pre_wp_mail', function ( $result, $atts ) {
        error_log( 'Staging: email not sent to ' . implode( ', ', (array) $atts['to'] ) . ': ' . $atts['subject'] );
        return true;
    }, 10, 2 );
}

The pre_wp_mail filter runs before WordPress sends any email. Ours writes a line to the log and tells WordPress the email went out, so forms don't show errors. Because it checks WP_ENVIRONMENT_TYPE from step 5, the file does nothing if it ever lands on the live site by mistake. Test it:

sudo -u shop-staging -- wp eval 'var_dump( wp_mail( "you@example.com", "Staging test", "Hello" ) );'

You should see Staging: email not sent to you@example.com: Staging test and bool(true), and no email should arrive. In our tests, a password reset through the staging login page sent nothing either, while the live site still sent its emails.

Two limits. A plugin that replaces WordPress's mail function completely, or one that talks to a newsletter service through its own connection, skips this filter. So in step 9, also turn off plugins that talk to outside services. And if live sends through an email service, as in our guide on keeping site emails out of spam, you can also remove the service's password from staging's wp-config.php.

9. Stop payments, backups and scheduled tasks

The copy also has your live API keys: for payments, backups to the cloud, newsletters and other connected services. A staging copy that runs them can charge real cards, send newsletters, or overwrite your real backups.

  • Scheduled tasks are off. DISABLE_WP_CRON from step 5 stops them from running on page visits. Don't add a cron job for staging, as you did for live in our real cron guide. When you want to test a task, run it by hand: sudo -u shop-staging -- wp cron event run --due-now.
  • List the active plugins with sudo -u shop-staging -- wp plugin list --status=active. Turn off the ones that reach outside services and that you don't need to test, for example a backup plugin: sudo -u shop-staging -- wp plugin deactivate plugin-name.
  • Payments: switch each payment plugin to its test or "sandbox" mode on staging, with test keys from your payment provider.
  • WooCommerce Subscriptions notices by itself that it runs at a new address. According to its docs, it then stops automatic renewal payments on the copy. We didn't test WooCommerce ourselves.

10. Hide staging from search engines and check it

The password already keeps search engines out, and Nginx sends a noindex header. Also tick WordPress's own "Discourage search engines" box (Settings → Reading), as a second lock in case the password is ever removed:

sudo -u shop-staging -- wp option update blog_public 0

Then check from the server, without and with the password:

curl -I https://staging.shop.example/
curl -I -u anna https://staging.shop.example/

The first should say 401. The second asks for the password and should say 200 with X-Robots-Tag: noindex, nofollow. Now open https://staging.shop.example in your browser. You see your posts and pages, and all links stay on the staging address. Log in at /wp-admin with your normal account: the users are copied too.

Never tick that box on the live site. It hides the whole site from search engines.

11. Make a fresh copy later

After a while, staging is out of date. To copy live over it again, save this script as /usr/local/sbin/refresh-staging-shop:

#!/bin/bash
# Copy live shop.example over the staging copy again.
set -euo pipefail
LIVE=/var/www/shop.example/public
STAGE=/var/www/staging.shop.example/public

# 1. Files (keeps staging's own wp-config.php and mail blocker)
rsync -a --delete \
  --exclude=/wp-config.php \
  --exclude=/wp-content/mu-plugins/staging-only.php \
  --exclude=/wp-content/cache/ \
  "$LIVE/" "$STAGE/"
chown -R shop-staging:shop-staging "$STAGE"

# 2. Database: empty staging, then copy live into it
sudo -u shop-staging -- wp --path="$STAGE" db clean --yes
sudo -u shop -- wp --path="$LIVE" db export - --single-transaction \
  | sudo -u shop-staging -- wp --path="$STAGE" db import -

# 3. Staging address, hidden from search engines
sudo -u shop-staging -- wp --path="$STAGE" search-replace '//shop.example' '//staging.shop.example' \
  --all-tables-with-prefix --skip-columns=guid --precise --report-changed-only
sudo -u shop-staging -- wp --path="$STAGE" search-replace '\/\/shop.example' '\/\/staging.shop.example' \
  --all-tables-with-prefix --skip-columns=guid --precise --report-changed-only
sudo -u shop-staging -- wp --path="$STAGE" option update blog_public 0
sudo -u shop-staging -- wp --path="$STAGE" cache flush
echo "Staging is now a fresh copy of live."
sudo chmod 700 /usr/local/sbin/refresh-staging-shop
sudo refresh-staging-shop

It wipes everything you changed on staging, files and database. wp db clean empties the staging database first, so no old tables are left behind. rsync may print cannot delete non-empty directory: wp-content/mu-plugins. That's fine: it's keeping your email blocker. Payment test modes and plugins you turned off in step 9 are back to the live settings, so do step 9 again after each refresh.

Moving changes back to live

Staging is for trying things. Getting them onto live needs care, because live keeps changing while you test. Again: don't copy the staging database over live. You would lose every order, comment, sign-up and form entry since the copy, and live would get the staging address and the "Discourage search engines" setting.

What to do instead:

  • Updates of WordPress, plugins and themes: once they work on staging, run the same updates on live. Take a backup first.
  • A theme or plugin you changed yourself: copy just that folder to live. Better still, keep it in Git and deploy it the same way to both. Take a backup first, then:
    sudo rsync -a --delete --chown=shop:shop \
      /var/www/staging.shop.example/public/wp-content/themes/my-theme/ \
      /var/www/shop.example/public/wp-content/themes/my-theme/
  • Settings, menus, widgets and new pages: make the same changes again on live, by hand. Write down what you changed on staging, so you don't forget anything. It feels slow, but it's the only way that never loses live data.
  • Large changes: some plugins can move selected content from staging to live. If you use one, try it first on a second copy, never straight on live, and keep a fresh backup.

Removing staging

Don't leave an old copy lying around: it holds your users and customer data, and its plugins stop getting updates. To remove everything this guide made:

sudo rm /etc/nginx/sites-enabled/staging.shop.example /etc/nginx/sites-available/staging.shop.example
sudo nginx -t && sudo systemctl reload nginx
sudo rm /etc/php/8.3/fpm/pool.d/shop-staging.conf
sudo systemctl reload php8.3-fpm
sudo mysql -e "DROP DATABASE shop_staging; DROP USER 'shop_staging'@'localhost';"
sudo certbot delete --cert-name staging.shop.example
sudo rm -r /var/www/staging.shop.example /etc/nginx/staging.shop.example.htpasswd
sudo gpasswd -d www-data shop-staging
sudo userdel shop-staging

Remove www-data from the group first (the gpasswd line). Otherwise userdel leaves the shop-staging group behind. Then delete the DNS record.

If something goes wrong

  • Staging sends you to the live site: the address wasn't changed. Check wp option get home and WP_HOME / WP_SITEURL in staging's wp-config.php, then run step 7 again.
  • "Error establishing a database connection": the name, user or password in staging's wp-config.php doesn't match step 2. Fix it with wp config set.
  • Nginx shows "502 Bad Gateway": the staging pool isn't running. Check that /run/php/shop-staging.sock exists, and run sudo php-fpm8.3 -t.
  • A theme or widget setting is empty on staging: the address was changed with a text tool instead of wp search-replace, and the packed data broke. Copy the database again (step 6) and use step 7.
  • The password box keeps coming back: the name or password is wrong, or Nginx can't read the file. Check that the file belongs to root:www-data with mode 640.
  • Images are missing on staging: the uploads folder wasn't copied, or belongs to the wrong user. Run step 4 again.
  • Site Health complains about "loopback requests" or scheduled events: that's expected. The password stops the site from calling itself, and WP-Cron is off on purpose.

Official sources

Next steps: if you're moving a site rather than testing it, see our guide to moving a PHP website to a new server. And keep the live site hardened and backed up.

← Back to the security blog