
A staging site only helps if it behaves like production. That means real HTTPS, correct URLs, working media, predictable cron, and no surprise email blasts.
This WordPress staging setup guide tutorial shows how to build a password-protected staging copy on a VPS with Nginx + PHP-FPM. It also adds guardrails so staging doesn’t become a liability.
If you host multiple WordPress installs, this pattern scales well. You can run one VPS, use separate vhosts, and repeat the same steps per client.
What you’ll build (and what you won’t)
- Build: a staging subdomain (
staging.example.com) on the same VPS as production, served over HTTPS - Build: a cloned database and file tree, with URLs rewritten safely
- Build: hard protections: Basic Auth +
noindex+ email suppression - Won’t: use a WordPress “staging plugin” that hides what it changes
- Won’t: change your production DNS or disrupt live traffic
Prerequisites checklist
- Ubuntu 24.04 LTS or Debian 12 on a VPS (root or sudo)
- Nginx 1.24+ and PHP 8.2/8.3 with PHP-FPM
- Two FQDNs in DNS:
example.com(production) andstaging.example.com(staging) - Enough disk to duplicate your WordPress files + database
If you’re sizing the server, assume staging roughly doubles storage. Expect short CPU bursts during testing.
For client hosting, start with a HostMyCode VPS. Scale up once you see real usage.
Step 1: Create a staging DNS record (short TTL first)
In your DNS provider, add:
- A record:
staging→ your VPS IPv4 - (Optional) AAAA record:
staging→ your VPS IPv6
Use a short TTL (60–300 seconds) while you build. After everything is stable, bump it to 900–3600 seconds.
Use the same DNS cutover habits you’d use for a migration. Lower TTL, verify, then raise it.
If you want the full workflow, see our DNS cutover tutorial for safe migrations.
Step 2: Create a staging web root and copy WordPress files
Assume production lives here:
/var/www/example.com/public
Create the staging paths:
sudo mkdir -p /var/www/staging.example.com/public
sudo chown -R www-data:www-data /var/www/staging.example.com
Copy the files. Use rsync so you can re-run it quickly:
sudo rsync -aHAX --delete \
/var/www/example.com/public/ \
/var/www/staging.example.com/public/
Tip: If you store uploads outside the docroot or use object storage, decide what staging should do with media.
Most teams copy uploads so staging can’t touch the production bucket. It costs space, but it avoids surprises.
Step 3: Clone the database (and create staging DB credentials)
Create a staging database and user. Example for MariaDB/MySQL:
sudo mysql
CREATE DATABASE wp_staging CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'wp_staging_user'@'localhost' IDENTIFIED BY 'REPLACE_WITH_STRONG_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 --lock-tables=false \
-u root -p wp_production | mysql -u root -p wp_staging
Large databases can spike disk I/O during dumps. Run this off-peak, or take snapshots before major testing.
Our snapshot backup tutorial outlines a safer “big change day” pattern.
Step 4: Point staging wp-config.php to staging DB (and add staging flags)
Edit staging wp-config.php:
sudo nano /var/www/staging.example.com/public/wp-config.php
Update the DB settings:
define('DB_NAME', 'wp_staging');
define('DB_USER', 'wp_staging_user');
define('DB_PASSWORD', 'REPLACE_WITH_STRONG_PASSWORD');
define('DB_HOST', 'localhost');
Add clear staging markers near the bottom (before “stop editing”):
// Staging safety flags
define('WP_ENVIRONMENT_TYPE', 'staging');
define('DISALLOW_FILE_MODS', true); // prevents plugin/theme updates in staging unless you temporarily flip it
define('AUTOMATIC_UPDATER_DISABLED', true);
Why disable file mods? You want staging to be a controlled test bench. You don’t want a second site that updates “just because.”
If you need to test updates, set DISALLOW_FILE_MODS to false briefly. Then lock it again.
Step 5: Rewrite URLs in the staging database (the safe way)
WordPress stores URLs in more places than most people expect. That includes serialized data.
Avoid raw SQL search/replace unless you’re confident about edge cases.
Use WP-CLI on the staging filesystem. If WP-CLI isn’t installed:
curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
php wp-cli.phar --info
sudo mv wp-cli.phar /usr/local/bin/wp
sudo chmod +x /usr/local/bin/wp
Run the replace from the staging docroot:
cd /var/www/staging.example.com/public
sudo -u www-data wp search-replace \
'https://example.com' 'https://staging.example.com' \
--all-tables --precise --skip-columns=guid
Then set the URL options explicitly. Some stacks need the nudge:
sudo -u www-data wp option update home 'https://staging.example.com'
sudo -u www-data wp option update siteurl 'https://staging.example.com'
If permissions or stuck updates get in the way, our WP-CLI troubleshooting tutorial covers the usual causes.
Step 6: Add a staging Nginx server block + PHP-FPM pool
Give staging its own vhost and its own PHP-FPM pool. A heavy test run shouldn’t starve production.
Create a dedicated PHP-FPM pool for staging
On Ubuntu/Debian with PHP 8.3, copy the default pool:
sudo cp /etc/php/8.3/fpm/pool.d/www.conf /etc/php/8.3/fpm/pool.d/staging.example.com.conf
Edit it:
sudo nano /etc/php/8.3/fpm/pool.d/staging.example.com.conf
Minimal, practical values for a staging pool:
[staging_example_com]
user = www-data
group = www-data
listen = /run/php/php8.3-fpm-staging-example-com.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = 10
pm.start_servers = 2
pm.min_spare_servers = 2
pm.max_spare_servers = 4
php_admin_value[memory_limit] = 256M
php_admin_value[max_execution_time] = 60
php_admin_value[upload_max_filesize] = 64M
php_admin_value[post_max_size] = 64M
Reload PHP-FPM:
sudo systemctl reload php8.3-fpm
If you want to tune pools for multiple WordPress sites on one VPS, see our PHP-FPM pool tuning tutorial.
Create the Nginx server block
Create:
sudo nano /etc/nginx/sites-available/staging.example.com
Example configuration (adjust paths and PHP version):
server {
listen 80;
server_name staging.example.com;
root /var/www/staging.example.com/public;
index index.php index.html;
access_log /var/log/nginx/staging.example.com.access.log;
error_log /var/log/nginx/staging.example.com.error.log;
# Block indexing at the edge too
add_header X-Robots-Tag "noindex, nofollow, nosnippet" always;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm-staging-example-com.sock;
}
location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico|webp)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800";
}
location ~ /\. {
deny all;
}
}
Enable and test:
sudo ln -s /etc/nginx/sites-available/staging.example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Step 7: Issue SSL for staging (Let’s Encrypt) and force HTTPS
If you use Certbot:
sudo apt update
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d staging.example.com
When prompted, choose the redirect option.
If renewals fail later, it’s usually DNS or a mismatched vhost. This guide stays focused: Let’s Encrypt renewal troubleshooting.
Step 8: Password-protect staging (Basic Auth) and add a second lock in WordPress
Basic Auth blocks casual access. It also reduces bot noise.
Create a password file:
sudo apt install -y apache2-utils
sudo htpasswd -c /etc/nginx/.htpasswd-staging yourname
Add this to your staging server block (inside server {}):
auth_basic "Staging";
auth_basic_user_file /etc/nginx/.htpasswd-staging;
Test and reload:
sudo nginx -t
sudo systemctl reload nginx
Second lock: In WordPress admin (staging), enable “Discourage search engines from indexing this site.”
Keep the Nginx X-Robots-Tag header anyway. That checkbox gets missed.
Step 9: Stop staging from sending real emails (critical)
This is the staging failure that actually hurts. A test can trigger password resets, WooCommerce receipts, or contact form replies to real customers.
You have three practical options:
- Block outbound SMTP at the server: best if staging never needs to send mail.
- Route staging mail to a sink address: useful for testing templates without delivering to customers.
- Use a staging mail provider inbox: workable for QA (still keep guardrails).
Option A: Block outbound SMTP ports from the staging host
If staging and production share the same VPS, don’t block SMTP globally. Only do it if you’re sure production won’t be affected.
On a shared VPS, use app-level controls (below) or put staging on a separate VPS.
If staging is on its own VPS, here’s a UFW example:
sudo ufw deny out 25/tcp
sudo ufw deny out 465/tcp
sudo ufw deny out 587/tcp
If you need a safe firewall baseline first, use our VPS firewall setup guide.
Option B: Force WordPress mail to a catch-all inbox
Install a mail plugin in staging only (not production). Configure it to reroute all outgoing mail to one address (for example, qa@example.com).
Keep Basic Auth enabled so random bots can’t trigger mail events.
Set a blunt sender name like “STAGING - Example.com” so nobody confuses it with production.
Option C: Disable WP cron and run a controlled system cron
Staging cron jobs can spam. They can also run expensive tasks at the worst time.
In staging wp-config.php:
define('DISABLE_WP_CRON', true);
Create a controlled cron (every 10 minutes is plenty for staging):
sudo crontab -u www-data -e
*/10 * * * * cd /var/www/staging.example.com/public && /usr/local/bin/wp cron event run --due-now --quiet
Step 10: Fix mixed content and “wrong scheme” issues fast
If staging loads over HTTPS but assets still point to HTTP, you’ll see blocked images, broken CSS, or redirect loops.
Quick diagnostics:
- Check WordPress URL settings:
wp option get home,wp option get siteurl - Check Nginx is passing HTTPS correctly if you use a reverse proxy/CDN
- Scan the page source for
http://example.comreferences
For redirect loops or “wrong scheme” behavior, use the focused checklist here: HTTPS redirect troubleshooting tutorial.
Step 11: Add a “staging banner” so nobody edits the wrong site
Mistakes happen, especially under time pressure. Add a visual banner so staging is unmistakable:
- Use your theme’s header hook, or a lightweight plugin in staging only.
- Make it explicit: “STAGING — changes not live.”
Also change the admin color scheme. Or tweak the staging site title so browser tabs stand out.
Step 12: Update safely, test, then repeat the clone (not the other way around)
A staging workflow works because it’s repeatable. The order matters:
- Clone production → staging (files + DB)
- Lock staging (Basic Auth + noindex)
- Disable email side effects
- Test updates (themes/plugins/PHP changes)
- Document what changed
- Apply the same changes to production in a controlled window
Keeping staging “ahead” for weeks usually backfires. It drifts, you stop trusting it, and troubleshooting costs more than re-cloning.
Troubleshooting cheatsheet (common staging breakages)
- Staging shows production content: you’re pointing to the wrong DB, or object cache is shared. Verify
DB_NAMEand disable Redis/object cache on staging. - Login redirects to production: the URL rewrite wasn’t complete. Re-run WP-CLI
search-replaceand verifyhome/siteurl. - Media library missing images: uploads weren’t copied, or permissions broke. Check
wp-content/uploadsownership:www-data:www-data. - Staging is slow: you reused the production PHP-FPM pool. Separate it and cap
pm.max_childrenso tests don’t starve live traffic. - SSL won’t issue: DNS isn’t pointing correctly or port 80 is blocked. Confirm with
dig staging.example.comand ensure Nginx listens on 80.
Production safety checklist (print this)
- Staging is on a separate hostname (
staging.) and never shares cookies with production. - Basic Auth enabled and tested.
X-Robots-Tag: noindexheader present on staging responses.- Outbound email is blocked or rerouted to a QA inbox.
- Staging has its own PHP-FPM pool with hard limits.
- Database search/replace done with WP-CLI (serialized-safe).
- Staging cron is controlled (or disabled).
- You can re-clone staging in under 20 minutes.
If you build staging environments for client sites, plan for consistent disk I/O and a little headroom for test spikes. Start with a HostMyCode VPS, or switch to managed VPS hosting if you’d rather ship changes than maintain servers.
FAQ
Should staging live on the same VPS as production?
For small sites, yes—if you isolate PHP-FPM pools and protect staging with auth. For busy WooCommerce stores, consider a second VPS so test spikes can’t impact checkout.
Do I need a separate SSL certificate for staging?
Yes. Issue a certificate for staging.example.com. Don’t reuse production certs manually; it creates renewal confusion and is harder to audit.
How do I prevent search engines from indexing staging?
Use multiple layers: Basic Auth, a noindex header (X-Robots-Tag), and WordPress “discourage indexing.” Any single layer can fail.
What’s the safest way to push staging changes to production?
Apply the same plugin/theme/code changes to production via your normal deployment path. Avoid “copy staging over production” unless you’re doing a full rebuild and understand the data-loss risk.
My staging site sends emails even after changes. What should I check?
Check SMTP plugins, WooCommerce hooks, and background jobs (Action Scheduler). If staging is on a separate VPS, blocking SMTP ports at the firewall ends the problem immediately.
Summary: a staging setup you can trust
A staging site should be boring. It loads over HTTPS, matches production behavior, and can’t get indexed or leak email.
Set it up once with separate PHP-FPM limits, serialized-safe URL rewrites, and hard access controls. Re-clone often instead of nursing a fragile, long-lived copy.
If you want zero resource contention, run staging on its own small VPS and scale only when needed. HostMyCode makes that straightforward with Affordable & Reliable Hosting sized for real WordPress work.