
Production WordPress fails in predictable, annoying ways. A plugin update tweaks rewrite rules. A theme update overwrites a customization. A PHP bump triggers a fatal error.
A staging copy removes the guesswork. This WordPress staging site tutorial shows you how to clone a live site onto your VPS, keep it private (and quiet), and push the right changes back without taking production down.
The steps assume an Ubuntu 24.04/26.04-class server with Nginx or Apache and PHP-FPM.
If you want a server you can grow into, start on a HostMyCode VPS. If you’d rather not maintain the OS, managed VPS hosting is usually the cleaner fit for businesses in 2026.
What you’ll build (and what you won’t)
You’ll create staging.yourdomain.com as a private clone of your live site:
- Separate vhost + separate webroot
- Separate database (so tests don’t touch production)
- Basic auth or IP allowlist to keep it private
- Robots/noindex plus hard blocks for common crawlers
- Email suppression so staging can’t spam customers
- A repeatable “pull from production” + “push to production” process
You won’t build a full Git-based deployment pipeline here. Git can be great. You just don’t need it to ship safer WordPress updates.
Prerequisites checklist (do this before cloning)
- DNS: You can create an A/AAAA record for staging.
- Disk space: You have enough room for a second copy of
wp-contentand a second database dump. - SSH access: Root or sudo on the server hosting WordPress.
- Backups: A recent offsite backup exists and you’ve done at least one restore test.
If your backup posture is “we have a plugin,” stop here.
Set up a real server-side backup plan. Then run a restore drill before you clone anything.
Two relevant guides: incremental backups with Restic + S3 and a full VPS restore drill.
Step 1: Create DNS and decide on access control
Create a DNS record:
- A record:
staging→ your server IPv4 - AAAA record (optional):
staging→ your server IPv6
Set TTL to 300 seconds while you build and test.
If you want a safer workflow for future cutovers, bookmark this: DNS propagation planning with rollback.
Now choose how you’ll keep staging private:
- Best default: HTTP Basic Auth (works anywhere)
- Good for teams on known networks: IP allowlist
- For client-facing staging: Basic Auth plus a shared password
Step 2: Create a staging webroot and copy WordPress files
On your VPS, find the production document root. Common paths include:
/var/www/yourdomain.com/public/var/www/html/home/username/public_html(shared hosting style)
Example layout we’ll use:
- Production:
/var/www/yourdomain.com/public - Staging:
/var/www/staging.yourdomain.com/public
sudo mkdir -p /var/www/staging.yourdomain.com/public
sudo rsync -a --delete \
/var/www/yourdomain.com/public/ \
/var/www/staging.yourdomain.com/public/
Important: You usually don’t want to copy caches.
If you have cache directories (varies by plugin), exclude them:
sudo rsync -a --delete \
--exclude 'wp-content/cache/' \
--exclude 'wp-content/uploads/cache/' \
/var/www/yourdomain.com/public/ \
/var/www/staging.yourdomain.com/public/
Set ownership to your web user (commonly www-data on Ubuntu):
sudo chown -R www-data:www-data /var/www/staging.yourdomain.com/public
Step 3: Clone the database into a separate staging database
Staging needs its own database. Otherwise, one “test” action can become a real production change.
Start by pulling production DB details from wp-config.php:
grep -E "DB_NAME|DB_USER|DB_HOST" /var/www/yourdomain.com/public/wp-config.php
Create a staging database and user. Replace values as needed:
sudo mysql
CREATE DATABASE wp_staging DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'wp_staging_user'@'localhost' IDENTIFIED BY 'use-a-long-random-password';
GRANT ALL PRIVILEGES ON wp_staging.* TO 'wp_staging_user'@'localhost';
FLUSH PRIVILEGES;
EXIT;
Dump production and import into staging:
mysqldump --single-transaction --quick --routines --triggers \
-u PROD_DB_USER -p PROD_DB_NAME > /root/prod.sql
mysql -u wp_staging_user -p wp_staging < /root/prod.sql
If the dump is large, compress it while streaming:
mysqldump --single-transaction --quick \
-u PROD_DB_USER -p PROD_DB_NAME | gzip > /root/prod.sql.gz
gunzip -c /root/prod.sql.gz | mysql -u wp_staging_user -p wp_staging
Step 4: Point staging WordPress at the staging database
Edit staging wp-config.php:
sudo nano /var/www/staging.yourdomain.com/public/wp-config.php
Update at least:
DB_NAME→wp_stagingDB_USER→wp_staging_userDB_PASSWORD→ your new password
While you’re in there, add a simple staging marker.
It makes staging easier to spot during testing:
define('WP_ENVIRONMENT_TYPE', 'staging');
Step 5: Update site URLs inside the staging database (search/replace)
Your database stores absolute URLs in home and siteurl. It also stores URLs inside content and plugin options.
Replace the production domain with the staging subdomain using a WordPress-aware tool. That avoids breaking serialized data.
If you have WP-CLI, use it. From the staging webroot:
cd /var/www/staging.yourdomain.com/public
sudo -u www-data wp option get home
sudo -u www-data wp option get siteurl
Run a dry-run first. Confirm what will change:
sudo -u www-data wp search-replace 'https://yourdomain.com' 'https://staging.yourdomain.com' --dry-run
Then run the real replacement:
sudo -u www-data wp search-replace 'https://yourdomain.com' 'https://staging.yourdomain.com' --all-tables
If production uses http internally but redirects to https, run the http variant too:
sudo -u www-data wp search-replace 'http://yourdomain.com' 'https://staging.yourdomain.com' --all-tables
Step 6: Configure the web server vhost for staging (Nginx or Apache)
Use a dedicated vhost for staging. Keep its document root, logs, and TLS separate from production.
Nginx example (Ubuntu)
Create /etc/nginx/sites-available/staging.yourdomain.com:
server {
listen 80;
server_name staging.yourdomain.com;
root /var/www/staging.yourdomain.com/public;
index index.php index.html;
access_log /var/log/nginx/staging.access.log;
error_log /var/log/nginx/staging.error.log;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp)$ {
expires 7d;
add_header Cache-Control "public";
}
}
Enable and reload:
sudo ln -s /etc/nginx/sites-available/staging.yourdomain.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Apache example (Ubuntu)
Create /etc/apache2/sites-available/staging.yourdomain.com.conf:
<VirtualHost *:80>
ServerName staging.yourdomain.com
DocumentRoot /var/www/staging.yourdomain.com/public
ErrorLog ${APACHE_LOG_DIR}/staging-error.log
CustomLog ${APACHE_LOG_DIR}/staging-access.log combined
<Directory /var/www/staging.yourdomain.com/public>
AllowOverride All
Require all granted
</Directory>
</VirtualHost>
Enable and reload:
sudo a2ensite staging.yourdomain.com
sudo apachectl configtest
sudo systemctl reload apache2
Step 7: Add HTTPS and fix common Let’s Encrypt failures
Issue a certificate for the staging subdomain. On Ubuntu in 2026, Certbot remains the simplest path.
Nginx:
sudo apt update
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d staging.yourdomain.com
Apache:
sudo apt update
sudo apt install -y certbot python3-certbot-apache
sudo certbot --apache -d staging.yourdomain.com
If issuance fails, don’t “try random things.”
The usual culprits are DNS pointing elsewhere, port 80 blocked, or the wrong vhost matching the request. This guide walks through the common fixes: fix Let’s Encrypt renewal/issuance failures.
Step 8: Keep staging private (Basic Auth + robots + hard blocks)
Staging should not show up in search results. It also should not be an easy target for brute-force logins.
Treat it like an internal tool, even if it’s publicly reachable.
Option A: HTTP Basic Auth (Nginx)
sudo apt install -y apache2-utils
sudo htpasswd -c /etc/nginx/.htpasswd-staging staginguser
Add to your staging server block:
auth_basic "Staging";
auth_basic_user_file /etc/nginx/.htpasswd-staging;
Reload Nginx:
sudo nginx -t
sudo systemctl reload nginx
Option B: HTTP Basic Auth (Apache)
sudo apt install -y apache2-utils
sudo htpasswd -c /etc/apache2/.htpasswd-staging staginguser
In the vhost:
<Directory /var/www/staging.yourdomain.com/public>
AuthType Basic
AuthName "Staging"
AuthUserFile /etc/apache2/.htpasswd-staging
Require valid-user
</Directory>
Reload Apache:
sudo apachectl configtest
sudo systemctl reload apache2
Robots and noindex
Create a staging-only robots.txt:
cat > /var/www/staging.yourdomain.com/public/robots.txt <<'EOF'
User-agent: *
Disallow: /
EOF
Also set a noindex header at the web server layer.
This helps when a crawler ignores robots.txt or never fetches it first.
Nginx inside the staging server block:
add_header X-Robots-Tag "noindex, nofollow, noarchive" always;
Apache inside the vhost:
Header always set X-Robots-Tag "noindex, nofollow, noarchive"
If you don’t already have headers enabled on Apache:
sudo a2enmod headers
sudo systemctl reload apache2
Step 9: Stop staging from sending real emails (critical)
Test emails are fine. Emailing real customers from staging is not.
Put a hard block in place so mistakes don’t turn into incidents.
Simple approach: route all WordPress mail to a sink address
Install a mail logging/sink plugin. Or use a must-use plugin so it can’t be disabled in the UI.
Create:
sudo mkdir -p /var/www/staging.yourdomain.com/public/wp-content/mu-plugins
sudo nano /var/www/staging.yourdomain.com/public/wp-content/mu-plugins/staging-mail-sink.php
Add:
<?php
/**
* Plugin Name: Staging Mail Sink
*/
add_filter('wp_mail', function($args){
$args['to'] = 'staging-inbox@yourdomain.com';
$args['subject'] = '[STAGING] ' . $args['subject'];
return $args;
});
This keeps mail flows testable (templates, hooks, attachments). It also forces all mail into a controlled inbox.
If you run email services on the same VPS, keep authentication correct. Otherwise, you can create deliverability problems later.
These two guides are solid references: SPF/DKIM/DMARC + rDNS setup and deliverability troubleshooting.
Step 10: Fix WordPress cron behavior for staging tests
Staging often has low traffic. That means wp-cron.php runs less often.
Scheduled tasks can look “broken” simply because nobody visited the site.
For realistic testing, switch to a real system cron.
Disable pseudo-cron in staging wp-config.php:
define('DISABLE_WP_CRON', true);
Add a cron job (as root) every 5 minutes:
sudo crontab -e
*/5 * * * * sudo -u www-data /usr/bin/php -d detect_unicode=0 /var/www/staging.yourdomain.com/public/wp-cron.php >/dev/null 2>&1
If scheduled posts still miss, this guide stays focused on the usual causes: WordPress cron troubleshooting.
Step 11: “Pull” workflow — refresh staging from production safely
Staging drifts over time. Settings change. Plugins come and go. Content keeps moving.
A controlled refresh keeps your tests honest.
- Put staging in maintenance mode (optional but tidy). You can do this via a plugin, or just warn your team.
- Resync files (exclude caches):
sudo rsync -a --delete \
--exclude 'wp-content/cache/' \
/var/www/yourdomain.com/public/ \
/var/www/staging.yourdomain.com/public/
- Dump production DB and import into staging (same commands as earlier).
- Run WP-CLI search/replace for URLs again.
- Re-apply staging-only settings: mail sink MU-plugin, noindex header, basic auth.
Pitfall: If you keep environment-specific values in wp-config.php (API keys, payment gateways), don’t overwrite staging’s config during rsync.
Exclude it:
sudo rsync -a --delete \
--exclude 'wp-config.php' \
--exclude 'wp-content/cache/' \
/var/www/yourdomain.com/public/ \
/var/www/staging.yourdomain.com/public/
Step 12: “Push” workflow — move changes from staging to production
Pushing is where people get burned. First, define what the “change” actually is.
In most WordPress workflows, the safest default is pushing code and configuration, not content.
What you should usually push
- Theme files (prefer child theme changes)
- Plugin updates (the plugin code)
- Configuration that lives in files:
wp-config.phpflags, Nginx/Apache config, PHP-FPM pool settings
What you should not push blindly
- The whole database (you’ll overwrite real orders, form submissions, and user changes)
wp-content/uploadsunless you know exactly what changed
Push plugin/theme updates using rsync (code only)
If you updated plugins on staging, push only wp-content/plugins (and/or mu-plugins and themes).
Example:
# Push plugins (careful: this overwrites production plugin code)
sudo rsync -a --delete \
/var/www/staging.yourdomain.com/public/wp-content/plugins/ \
/var/www/yourdomain.com/public/wp-content/plugins/
# Push themes
sudo rsync -a --delete \
/var/www/staging.yourdomain.com/public/wp-content/themes/ \
/var/www/yourdomain.com/public/wp-content/themes/
Then clear caches (varies by stack). If you run PHP-FPM, a reload clears opcode cache:
sudo systemctl reload php8.3-fpm
For a busy store, schedule this for a quiet window. Verify key flows right after (checkout, login, contact forms).
If you need help planning a low-downtime move or maintenance window on new infrastructure, HostMyCode migrations can handle the cutover steps and verification.
Push specific database changes (only when needed)
Sometimes you do need DB changes. Common examples are a settings toggle, a new plugin option, or a redirect rule stored in the DB.
Even then, avoid exporting/importing the full database.
Two safer patterns:
- Reproduce the change manually in production while you have a checklist. Slower, but you keep control.
- Export a narrow set of tables if you’re confident which tables a plugin uses (still risky if they contain live data).
If you choose the narrow export route, start by identifying which tables changed on staging.
If you have binary logs and auditing, use them. If not, compare row counts before/after. Also confirm the tables don’t hold customer data.
Step 13: Performance sanity checks on staging (so you don’t ship surprises)
Staging is where you catch slow pages before customers feel them. A quick triage usually finds the obvious problems.
- PHP errors: scan logs for fatals and repeated warnings
- Time to first byte: compare before/after a plugin update
- Disk and CPU spikes: watch during imports, indexing, or crawler runs
Useful commands:
# Nginx errors
sudo tail -n 200 /var/log/nginx/staging.error.log
# Apache errors
sudo tail -n 200 /var/log/apache2/staging-error.log
# PHP-FPM status (if enabled) and system load
uptime
free -h
df -h
If you’re tuning PHP for WordPress, don’t guess.
This guide covers practical pool sizing and settings that hold up under load: PHP-FPM performance tuning for WordPress.
Step 14: Security guardrails you can apply in 15 minutes
Staging sites get forgotten. Attackers don’t forget them.
Add a few guardrails to avoid the usual mess.
- Keep staging behind auth (Basic Auth or IP allowlist).
- Disable XML-RPC on staging unless you need it.
- Limit admin accounts and force strong passwords.
- Patch the OS and PHP regularly.
If staging and production share a VPS, harden the box once. Then keep it maintained.
Two practical starting points: hardening a new Ubuntu VPS for hosting and automated maintenance routines.
Step 15: A repeatable staging checklist (print this)
- DNS:
stagingrecord points correctly, TTL sensible. - Vhost: separate logs, correct docroot, correct PHP-FPM socket/version.
- TLS: Let’s Encrypt cert issued and auto-renewing.
- Privacy: Basic Auth/IP allowlist,
X-Robots-Tag,robots.txt. - Email: mail sink enabled, subject prefixed with
[STAGING]. - Database: separate DB/user, URLs replaced with WP-CLI.
- Cron: real cron enabled for realistic scheduled tasks.
- Push plan: code-only push by default; DB changes only with explicit scope.
- Backups: staging included or explicitly excluded (but never breaks production backups).
If you run staging and production on the same box, you’ll hit limits fast: disk, RAM, and test loads that compete with real traffic. A HostMyCode VPS gives you predictable resources for safe staging workflows, and managed VPS hosting is the easiest way to keep WordPress, PHP, and TLS maintained in 2026.
FAQ
Should staging live on the same VPS as production?
For small sites, yes—if you isolate vhosts and databases and keep staging private. For busy stores, a separate staging box prevents tests from stealing CPU and I/O from paying customers.
Do I need a separate database user for staging?
Yes. It limits blast radius and makes it obvious which credentials belong to staging. It also helps prevent scripts and tools from accidentally connecting to production.
Why did my staging site break after search-replace?
Most commonly: you used a replace tool that isn’t serialization-safe and corrupted serialized data. Use wp search-replace (or an equivalent WordPress-aware tool), then rerun it carefully.
Can I push my staging database to production to deploy changes?
A full DB overwrite is rarely safe. Push code, then reproduce settings changes explicitly. If you must move data, scope it to specific tables and confirm they don’t contain live orders or submissions.
How do I keep staging from showing up in Google?
Use Basic Auth or IP allowlisting (best), plus X-Robots-Tag: noindex and a Disallow: / robots file. Don’t rely on robots.txt alone.
Summary: staging turns risky updates into routine work
A staging site isn’t a luxury in 2026. It’s how you update WordPress without gambling with uptime or revenue.
Clone files and the database. Replace URLs safely. Lock staging down. Suppress emails.
Then push code back to production using a clear checklist.
If you want staging and production to behave consistently under load, run them on infrastructure that stays predictable.
Start with a HostMyCode VPS, and move to dedicated servers once traffic and workflows justify it.