Building a VPN-Restricted-Access WordPress Dev Mirror Server

Objective

Set up a WordPress development mirror that is reachable only over VPN, mirrors the
production site (kevwells.com), and is safe for testing theme, plugin, and content changes without
public 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 and blog_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.
By Kevin Wells — Linux IT Consultant