A precise, scriptable workflow to mirror a live WordPress instance to a local network node for testing and development without leaking secrets or damaging production.
Quick start
- Enable SSH key access from the workstation to the production host. No passwords.
- Configure local MySQL authentication via
~/.my.cnf
or a MySQL login-path. - Install WP-CLI and verify it can operate the local WordPress docroot as
www-data
. - Set the environment variables in the script header to match your environment.
- Run the script. It will pull a production DB dump, rsync files into a staging area, sync into the local docroot with correct ownership and permissions, reset tables, import the dump, and rewrite URLs to your local domain.
Why this design
- No root SSH over the network. All network operations are unprivileged. Elevated actions are local only.
- Local first. Database dump is streamed to the workstation and compressed locally. No lingering dumps on the server.
- Two phase file sync. Remote to user staging, then to local docroot. Prevents accidental overwrite of local configuration and enforces clean permissions.
- Idempotent. Safe to re run. The script preserves
wp-config.php
, resets only tables, and re applies URL localisation deterministically. - Minimal secrets surface. No credentials embedded. Local MySQL credentials are loaded from client config. Remote dump uses server side defaults or a login path.
Architecture at a glance
# Production host
mysqldump | gzip ──> Workstation: $HOME/sql/<name>_YYYY-MM-DD_HHMM.sql.gz
# Production docroot
rsync over SSH ──> $HOME/.staging/wordpress ──> /var/www/wordpress (local docroot, www-data owned)
# WP-CLI on workstation (as www-data)
db reset → db import → set siteurl/home → search-replace prod→local → flush caches and rewrites
Prerequisites
- Production: SSH key access for a non root user. Ability to run
mysqldump
via server side defaults or a login path. - Local: Linux,
ssh
,rsync
,gzip
,mysql
,wp
, andflock
. Local WordPress docroot is owned bywww-data
. - Version alignment: Keep PHP and MySQL close between production and local to avoid serialisation or plugin surprises.
Setup and configuration
/var/www/wordpress # Local WordPress docroot (www-data owned)
$HOME/.staging/wordpress # User owned staging area
$HOME/sql # Compressed DB dumps (.sql.gz)
$HOME/logs # Script logs
$HOME/locks # Lock files
Verify WP-CLI can manage the docroot as www-data
:
sudo -u www-data wp --path=/var/www/wordpress core version
Replication workflow
- Pre checks: verify commands, SSH connectivity, client auth, paths, and acquire a lock.
- DB dump: run
mysqldump
remotely, stream over SSH, compress to$HOME/sql/<db>_timestamp.sql.gz
. - File sync: rsync remote docroot to staging with safe excludes. Then rsync staging to local docroot with ownership and mode fixes. Preserve local
wp-config.php
. - Reset and import: reset tables via WP-CLI, import fresh dump, set
siteurl
andhome
to local URL. - Localise and harden: optional theme install, disable all plugins for first boot, flush rewrites and caches, perform broad URL search replace of production hosts to local.
Sanitised automation script (public version)
Secrets removed by design. Replace placeholders with your values via environment variables. Do not publish internal hostnames or user names.
#!/usr/bin/env bash
# File: ~/.local/bin/pull_kevwells
# Purpose: Pull WordPress (files + DB) from production → local workstation.
# Policy: No root SSH. Network ops as user; sudo only for LOCAL stage→docroot copy.
# Guarantees: preserves local wp-config.php; runs WP-CLI as www-data; auto URL rewrite to LOCAL_URL.
set -Eeuo pipefail
export LANG=C.UTF-8
export LC_ALL=C.UTF-8
export HOME="${HOME:-/home/you}"
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/bin:$HOME/.local/bin"
# ----- Remote selection (primary with fallback; override via REMOTE_SSH) -----
if [ -z "${REMOTE_SSH:-}" ]; then
SSH_PROBE_OPTS="-o BatchMode=yes -o ConnectTimeout=5"
if ssh $SSH_PROBE_OPTS "${REMOTE_SSH_PRIMARY:-user@prod-host}" true 2>/dev/null; then
REMOTE_SSH="${REMOTE_SSH_PRIMARY:-user@prod-host}"
else
REMOTE_SSH="${REMOTE_SSH_FALLBACK:-user@prod-host-backup}"
fi
fi
# ----- Core config (override with env as needed) -----
: "${REMOTE_WP:=/var/www/wordpress}" # Production docroot
: "${REMOTE_DB_NAME:=wordpress}" # Production DB name (server-side auth/login-path expected)
: "${LOCAL_WP_ROOT:=/var/www/wordpress}" # Local docroot (must match local wp-config.php)
: "${LOCAL_DB_NAME:=wordpress}" # For logging only; WP-CLI reads local wp-config.php
: "${SSH_KEY:=$HOME/.ssh/id_ed25519}" # Or id_rsa if you insist
: "${SSH_OPTS:="-o BatchMode=yes -i $SSH_KEY -o ConnectTimeout=20 -o ServerAliveInterval=15 -o ServerAliveCountMax=3"}"
: "${STAGE_ROOT:=$HOME/.staging/wordpress}" # User-owned staging area
: "${LOCAL_SQL_DIR:=$HOME/sql}"
: "${LOG_DIR:=$HOME/logs}"
: "${LOCK_DIR:=$HOME/locks}"
: "${LOG_FILE:=$LOG_DIR/pull_wordpress_local_clone.log}"
# Local URL and production domain (used for search-replace)
: "${LOCAL_URL:=http://wp.local.test}"
: "${PROD_DOMAIN:=example.com}" # Your public domain (no scheme, no path)
: "${FALLBACK_THEME:=generatepress}" # Optional: ensure a sane theme exists
# rsync options
RSYNC_BASE_OPTS=( -aHAXx --delete --numeric-ids --inplace --info=stats1,progress2 )
RSYNC_REMOTE_EXCLUDES=(
--exclude 'wp-config.php'
--exclude 'wp-content/cache/'
--exclude 'wp-content/backups/'
--exclude 'wp-content/mu-plugins/replica-host-rewrite.php'
--exclude 'info.php'
)
RSYNC_LOCAL_FIXUPS=(
--no-perms --no-owner --no-group
--chown=www-data:www-data
--chmod=Dug=rwx,Do=,Fug=rw,Fo=
--exclude 'wp-config.php'
)
# ----- Helpers -----
ts() { date +"%Y-%m-%d %H:%M:%S"; }
log() { echo "[$(ts)] $*" | tee -a "$LOG_FILE"; }
die() { log "ERROR: $*"; exit "${2:-1}"; }
need(){ command -v "$1" >/dev/null 2>&1 || die "Missing command: $1" 10; }
wpw(){ sudo -u www-data wp --path="$LOCAL_WP_ROOT" "$@"; } # WP-CLI as www-data
prechecks(){
mkdir -p "$STAGE_ROOT" "$LOCAL_WP_ROOT" "$LOCAL_SQL_DIR" "$LOG_DIR" "$LOCK_DIR"
: > "$LOG_FILE" || true
for cmd in ssh rsync gzip gunzip zcat mysql wp flock; do need "$cmd"; done
ssh $SSH_OPTS "$REMOTE_SSH" true 2>/dev/null || die "SSH to $REMOTE_SSH failed (keys/BatchMode)."
mysql -e "SELECT 1" >/dev/null 2>&1 || die "Local MySQL cannot auth via ~/.my.cnf or login-path."
}
main(){
prechecks
exec {lockfd}>"$LOCK_DIR/pull_kevwells.lock"
flock -n "$lockfd" || die "Another pull is already running (lock active)."
TS="$(date +%F_%H%M)"
DUMP_PATH="${LOCAL_SQL_DIR}/${REMOTE_DB_NAME}_${TS}.sql.gz"
log "START pull (remote=${REMOTE_SSH}, remote_db=${REMOTE_DB_NAME}, local_url=${LOCAL_URL})"
# [1/7] Remote DB dump → local compressed file
log "[1/7] Dumping remote DB → $DUMP_PATH"
ssh $SSH_OPTS "$REMOTE_SSH" \
"mysqldump --single-transaction --hex-blob --default-character-set=utf8mb4 '$REMOTE_DB_NAME'" \
| gzip -c > "$DUMP_PATH"
# [2/7] Rsync remote → staging (user-space)
log "[2/7] Rsync remote → staging: '$REMOTE_SSH:$REMOTE_WP/' → '$STAGE_ROOT/'"
rsync "${RSYNC_BASE_OPTS[@]}" "${RSYNC_REMOTE_EXCLUDES[@]}" \
-e "ssh $SSH_OPTS" \
"$REMOTE_SSH:$REMOTE_WP/" \
"$STAGE_ROOT/"
# [3/7] Sanity check
[ -d "$STAGE_ROOT/wp-content" ] || die "Staging incomplete: '$STAGE_ROOT/wp-content' missing."
# [4/7] Safety backup of local wp-config.php (if present)
LOCAL_WPCFG="$LOCAL_WP_ROOT/wp-config.php"
LOCAL_WPCFG_BAK="$LOCAL_WP_ROOT/.wp-config.php.localbak"
if [ -f "$LOCAL_WPCFG" ]; then
sudo cp -a "$LOCAL_WPCFG" "$LOCAL_WPCFG_BAK"
fi
# [5/7] Stage → local docroot (preserve local wp-config.php; fix ownership/perms)
log "[5/7] Rsync staging → local docroot: '$STAGE_ROOT/' → '$LOCAL_WP_ROOT/'"
sudo rsync "${RSYNC_BASE_OPTS[@]}" "${RSYNC_LOCAL_FIXUPS[@]}" \
"$STAGE_ROOT/" \
"$LOCAL_WP_ROOT/"
# Post-rsync guard: ensure wp-config.php exists; restore backup if needed
if [ ! -f "$LOCAL_WPCFG" ] && [ -f "$LOCAL_WPCFG_BAK" ]; then
log "WARNING: wp-config.php missing after rsync; restoring local backup."
sudo mv -f "$LOCAL_WPCFG_BAK" "$LOCAL_WPCFG"
sudo chown www-data:www-data "$LOCAL_WPCFG"
sudo chmod 640 "$LOCAL_WPCFG"
fi
[ -f "$LOCAL_WPCFG" ] || die "Local '$LOCAL_WP_ROOT/wp-config.php' missing and no backup available."
# [6/7] Reset tables only (no DROP/CREATE DATABASE)
log "[6/7] Resetting tables via WP-CLI"
wpw db reset --yes
# [7/7] Import dump and localise
log "[7/7] Importing DB and localising site"
TMP_SQL="$(mktemp)"
gunzip -c "$DUMP_PATH" > "$TMP_SQL"
IMPORT_SQL="$LOCAL_WP_ROOT/_import.sql"
sudo install -o www-data -g www-data -m 0640 "$TMP_SQL" "$IMPORT_SQL"
rm -f "$TMP_SQL"
wpw db import "$IMPORT_SQL"
sudo rm -f "$IMPORT_SQL"
# Core URL settings
wpw option update home "$LOCAL_URL"
wpw option update siteurl "$LOCAL_URL"
# Theme & rewrites hardening for first boot
wpw theme is-installed "$FALLBACK_THEME" >/dev/null 2>&1 || wpw theme install "$FALLBACK_THEME"
wpw option update active_plugins "" >/dev/null 2>&1 || true
wpw rewrite flush --hard >/dev/null 2>&1 || true
# Replace production URLs broadly
LOCAL_BASE="${LOCAL_URL%/}"
PROD_HOSTS=(
"https://${PROD_DOMAIN}"
"http://${PROD_DOMAIN}"
"//${PROD_DOMAIN}"
"https://www.${PROD_DOMAIN}"
"http://www.${PROD_DOMAIN}"
"//www.${PROD_DOMAIN}"
)
for OLD in "${PROD_HOSTS[@]}"; do
wpw search-replace "$OLD" "$LOCAL_BASE" \
--all-tables --precise --report-changed-only --skip-columns=guid >/dev/null
done
# Clear caches and flush rewrites again
wpw transient delete --all >/dev/null 2>&1 || true
wpw cache flush >/dev/null 2>&1 || true
wpw rewrite flush --hard >/dev/null 2>&1 || true
log "Post-localise URLs:"
wpw --skip-plugins --skip-themes option get siteurl | sed "s/^/ siteurl = /"
wpw --skip-plugins --skip-themes option get home | sed "s/^/ home = /"
log "COMPLETE. Files: $LOCAL_WP_ROOT | DB: $LOCAL_DB_NAME | Visit: $LOCAL_URL"
}
trap 'die "Trapped error on line $LINENO." 99' ERR
main
Environment variables to set (examples):
export REMOTE_SSH_PRIMARY="deploy@example.com"
export REMOTE_SSH_FALLBACK="deploy@bastion.example.net"
export PROD_DOMAIN="example.com"
export LOCAL_URL="http://wp.local.test"
# Optional overrides:
# export LOCAL_WP_ROOT="/var/www/wordpress"
# export REMOTE_WP="/var/www/wordpress"
# export REMOTE_DB_NAME="wordpress"
Operational playbook
- First run: execute interactively and watch for permission prompts and WP-CLI output.
- Regular use: schedule before development sessions. Prefer a lock guarded cron entry.
# /etc/cron.d/wp_local_refresh - runs at 06:30 on weekdays
30 6 * * 1-5 youruser PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/bin:$HOME/.local/bin \
PROD_DOMAIN=example.com LOCAL_URL=http://wp.local.test \
REMOTE_SSH_PRIMARY=deploy@example.com REMOTE_SSH_FALLBACK=deploy@bastion.example.net \
/home/youruser/.local/bin/pull_kevwells >> /home/youruser/logs/pull_cron.log 2>&1
Rollback to a previous dump:
gunzip -c ~/sql/wordpress_2025-09-19_0630.sql.gz | \
sudo -u www-data wp --path=/var/www/wordpress db import -
sudo -u www-data wp --path=/var/www/wordpress option update home http://wp.local.test
sudo -u www-data wp --path=/var/www/wordpress option update siteurl http://wp.local.test
Housekeeping for old dumps:
find "$HOME/sql" -type f -name '*.sql.gz' -mtime +14 -delete
Security notes and threat model
- No secrets in the script. Local DB auth via
~/.my.cnf
or login path. Remote dump uses server side defaults or a read only DB user. - SSH keys only. Disable password authentication. Consider forced command on the server side key if you want to be strict.
- Least privilege. Network phase is unprivileged.
sudo
is used only to write to the local docroot and run WP-CLI aswww-data
. - Pull only. This is designed for a developer workstation, not production.
- Preserve local configuration. The script never overwrites local
wp-config.php
. It backs up and restores if needed. - Do not publish internals. Replace
example.com
, SSH targets, and paths with site neutral values in public material.
Troubleshooting
Symptom | Likely cause | Remedy |
---|---|---|
SSH to host failed (keys or BatchMode) | No key auth or wrong key path | Fix SSH_KEY , ensure the public key is installed on the server, validate sshd_config . |
Local MySQL cannot auth via ~/.my.cnf |
Missing client credentials | Create ~/.my.cnf or set a login path via mysql_config_editor . |
Error establishing database connection | Local wp-config.php points at wrong DB or socket |
Correct DB settings, confirm local MySQL is running, and check socket paths. |
Broken CSS or media | URLs not fully replaced or stale caches | Re run search replace, clear transients and plugin caches, flush rewrites. |
Permission denied on upload | Docroot not owned by www-data |
Re run the script to enforce ownership and modes or fix manually. |
Plugin specific fatal errors | Plugin incompatible with local PHP or MySQL | Align versions or keep plugins disabled for the first boot and enable selectively. |
Extensions and variants
- Staging server: use the same script to refresh a staging host by adjusting
LOCAL_URL
andLOCAL_WP_ROOT
. - Selective sync: add further rsync excludes for large media if you want a smaller, faster clone for daily work.
- Systemd timers: prefer systemd timers over cron on modern distros for stronger logging and reliability.
- Read only DB user: constrain the production dump account to
SELECT
,SHOW VIEW
, and routine read privileges on the WordPress schema only. - Integrity checks: add
wp core verify-checksums
post import to catch corrupted core files quickly.