Objective
production site (
kevwells.com
), and is safe for testing theme, plugin, and content changes withoutpublic exposure or SEO duplication.
Design Principles
- Single Apache, multiple vhosts: no second web server. Isolation via a dedicated VirtualHost.
- VPN-only access: bind the dev vhost to the VPN interface/IP; do not expose to LAN/WAN or public internet.
- Separate everything: distinct filesystem and database for dev; never share prod tables.
- Safe by default: no indexing, outbound mail disabled, prod-only plugins off.
- Simple sync: rsync files +
wp db export|import
pipe; URL rewrite with WP-CLI.
Prerequisites
- Linux host running Apache 2.4, PHP-FPM (8.x), and MariaDB/MySQL.
- Production WordPress tree at
/var/www/wordpress
(or equivalent). - WP-CLI installed at
/usr/local/bin/wp
. - Operational VPN with a stable interface/IP (e.g., ZeroTier/WireGuard).
- Ability to manage DNS/hosts for a private hostname (e.g.,
dev.kevwells.lan
).
1) Private Name Resolution (VPN only)
Publish the dev hostname exclusively over the VPN. Either Managed DNS on your VPN controller or client /etc/hosts
entries:
<VPN_IP> dev.kevwells.lan
Do not override kevwells.com
locally; it must continue to resolve via public DNS to your public IP.
2) Apache VirtualHost (bind to VPN interface)
Create /etc/apache2/sites-available/500-dev.kevwells.lan.conf
:
<VirtualHost <VPN_IP>:80>
ServerName dev.kevwells.lan
DocumentRoot /var/www/kevwells-dev
<Directory /var/www/kevwells-dev>
Options FollowSymLinks
AllowOverride All
Require ip <VPN_CIDR> # e.g. 10.0.0.0/16
</Directory>
Header set X-Robots-Tag "noindex, nofollow, noarchive"
ErrorLog ${APACHE_LOG_DIR}/kevwells-dev_error.log
CustomLog ${APACHE_LOG_DIR}/kevwells-dev_access.log combined
</VirtualHost>
Enable and reload:
sudo a2enmod headers
sudo a2ensite 500-dev.kevwells.lan.conf
sudo apachectl configtest && sudo systemctl reload apache2
sudo apachectl -t -D DUMP_VHOSTS | grep -A1 dev.kevwells.lan
Optional: add a 443 vhost with an internal CA/mkcert certificate, reusing the same Require ip
gate.
3) Dev Database
CREATE DATABASE kevwells_dev CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'kw_dev'@'localhost' IDENTIFIED BY 'REPLACE_WITH_STRONG_PASSWORD';
GRANT ALL PRIVILEGES ON kevwells_dev.* TO 'kw_dev'@'localhost';
FLUSH PRIVILEGES;
4) Dev Filesystem & wp-config.php
sudo mkdir -p /var/www/kevwells-dev
sudo chown -R www-data:www-data /var/www/kevwells-dev
# Create a clean dev config:
sudo -u www-data wp --path=/var/www/kevwells-dev config create \
--dbname=kevwells_dev --dbuser=kw_dev --dbpass='REPLACE_WITH_STRONG_PASSWORD' \
--dbhost=localhost --skip-check
Ensure these constants are present (note the quoted strings):
define( 'DB_CHARSET', 'utf8mb4' );
define( 'WP_HOME', 'http://dev.kevwells.lan' );
define( 'WP_SITEURL', 'http://dev.kevwells.lan' );
define( 'WP_ENVIRONMENT_TYPE', 'development' );
define( 'DISALLOW_FILE_EDIT', true );
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );
Rule of thumb: use --raw
only for booleans/integers; never for strings.
5) Initial Clone from Live (local, no SSH)
If live and dev are on the same host, clone locally for simplicity:
LIVE_WP="/var/www/wordpress"
DEV="/var/www/kevwells-dev"
DEV_URL="http://dev.kevwells.lan"
# Files (keep dev wp-config.php)
rsync -a --delete \
--exclude='wp-config.php' \
--exclude='wp-content/cache/' --exclude='wp-content/upgrade/' \
--exclude='wp-content/advanced-cache.php' --exclude='wp-content/object-cache.php' \
"$LIVE_WP/" "$DEV/"
# DB: export prod → import dev
sudo -u www-data wp --path="$LIVE_WP" db export - \
| sudo -u www-data wp --path="$DEV" db import -
# URL rewrite (serialized-safe)
sudo -u www-data wp --path="$DEV" search-replace \
'https://kevwells.com' "$DEV_URL" --all-tables --precise --skip-columns=guid
# Safety
sudo -u www-data wp --path="$DEV" option update blog_public 0
sudo -u www-data wp --path="$DEV" plugin deactivate wordfence ithemes-security litespeed-cache w3-total-cache || true
6) Mail Sink (belt-and-braces)
Prevent accidental emails from dev by adding an MU-plugin:
<?php
add_filter('wp_mail', function($args){
error_log('[DEV EMAIL BLOCKED] '.json_encode($args));
return false;
});
Save as wp-content/mu-plugins/disable-mail.php
.
7) Verification
# On the server
sudo -u www-data wp --path=/var/www/kevwells-dev option get blog_public # expect 0
sudo -u www-data wp --path=/var/www/kevwells-dev config get WP_HOME # dev URL
sudo -u www-data wp --path=/var/www/kevwells-dev config get WP_SITEURL # dev URL
# From a VPN client (connected)
getent hosts dev.kevwells.lan # resolves to <VPN_IP>
curl -sI http://dev.kevwells.lan # 200/301
# Disconnect VPN → resolution/connection should fail
8) Routine Refresh (one command)
Install a local refresh script to pull files + DB, rewrite URLs, and enforce safety:
sudo tee /usr/local/sbin/wp_sync_live_to_dev_local >/dev/null <<'BASH'
#!/usr/bin/env bash
set -Eeuo pipefail
LIVE_WP="/var/www/wordpress"
DEV="/var/www/kevwells-dev"
DEV_URL="http://dev.kevwells.lan"
# Guard config strings
sudo sed -i "s|define( 'WP_HOME', .*);|define( 'WP_HOME', 'http://dev.kevwells.lan' );|" "$DEV/wp-config.php" || true
sudo sed -i "s|define( 'WP_SITEURL', .*);|define( 'WP_SITEURL', 'http://dev.kevwells.lan' );|" "$DEV/wp-config.php" || true
sudo sed -i "s|define( 'DB_CHARSET', 'utf8' );|define( 'DB_CHARSET', 'utf8mb4' );|" "$DEV/wp-config.php" || true
sudo -u www-data wp --path="$DEV" maintenance-mode activate || true
rsync -a --delete \
--exclude='wp-config.php' \
--exclude='wp-content/cache/' --exclude='wp-content/upgrade/' \
--exclude='wp-content/advanced-cache.php' --exclude='wp-content/object-cache.php' \
"$LIVE_WP/" "$DEV/"
sudo -u www-data wp --path="$LIVE_WP" db export - \
| sudo -u www-data wp --path="$DEV" db import -
sudo -u www-data wp --path="$DEV" search-replace \
'https://kevwells.com' "$DEV_URL" --all-tables --precise --skip-columns=guid
sudo -u www-data wp --path="$DEV" option update blog_public 0
sudo -u www-data wp --path="$DEV" plugin deactivate wordfence ithemes-security litespeed-cache w3-total-cache || true
sudo -u www-data wp --path="$DEV" maintenance-mode deactivate || true
echo "dev mirror refreshed."
BASH
sudo chmod +x /usr/local/sbin/wp_sync_live_to_dev_local
Operational Notes
- Parity: keep dev on the same major PHP/MariaDB versions as prod. Test upgrades in a disposable sandbox, not here.
- SEO: confirm
X-Robots-Tag: noindex
andblog_public=0
after each refresh. - Webhooks: disable in dev or switch to “test mode.”
- Backups: exclude dev DB/files from offsite backups unless explicitly required (the mirror is reproducible).
Troubleshooting
- WP-CLI parse error (unexpected token ‘;’): you’ve set string constants without quotes in
wp-config.php
. Quote them. mysqldump errno 32
during refresh: the import side crashed (usually the config issue above). Fix config; re-run.- Dev reachable without VPN: you didn’t bind the vhost to the VPN IP or you removed the
Require ip
gate. Fix both. - Host-header tricks: IP-bound vhost prevents public hits with a fake
Host:
header from matching the dev site.