The safest way to migrate WordPress to a new host without losing data is to prepare the destination first, take an initial copy, apply a durable barrier or synchronize writes, run a final delta, test the new server against the real hostname, and change DNS only when you have a proven rollback path. Keep the old host online and observe both servers after the cutover. This workflow adds controls for orders, form submissions, registrations, and editorial changes to the baseline process in the official WordPress migration documentation.
“Without downtime” is a goal, not a universal guarantee. A mostly static publishing site can get very close. WooCommerce, Easy Digital Downloads, membership sites, and active communities need a brief read-only window, application-aware synchronization, or infrastructure that can replicate writes. The honest target is minimal downtime with no unaccounted-for writes, not a promise of zero seconds.
Choose the migration method before copying data
Ways to move WordPress to a new host
| Method | Best fit | Advantage | Limit to verify |
|---|---|---|---|
| Provider-assisted migration | The new provider offers it and accepts your site size, stack, and workload. | Reduces manual work and centralizes coordination. | Get the scope in writing: email, DNS, testing, write window, and rollback are not automatically included. |
| Migration plugin | The site fits the tool's limits and the source remains reachable. | Packages files, the database, and serialized replacements. | PHP limits, storage, firewalls, multisite, timeouts, or a busy store can break the workflow. |
| Control-panel transfer | The panels are compatible or the destination can restore a full account backup. | May move accounts, databases, email, and settings together. | Check versions, owners, paths, DNS, certificates, and anything the panel excludes. |
| Manual SSH, rsync, and WP-CLI | You operate both environments and need control over each phase. | Supports an initial copy, final delta, preflight testing, and detailed records. | Requires server, database, permission, TLS, and recovery experience. |
Use the simplest route that still lets you control writes and return safely. A plugin does not solve data consistency: an order placed after the database was packaged will not appear on the destination by itself. Likewise, a successful panel restore does not prove that the application works.
If the destination is not ready, start with how to choose web hosting and how to install WordPress on hosting or a VPS. Verify that the plan supports the site's size, traffic pattern, and required versions.
Inventory the site and set go/no-go criteria
Begin with an operational snapshot. Give every check an owner, and do not approve cutover while a critical fact is missing:
- Application: WordPress, PHP, and database versions; plugins, theme, mu-plugins, multisite, domain mapping, persistent object cache, and custom code.
- Capacity: file and database size, inode usage, temporary and free space, upload limits, memory, and execution time.
- Server: document root, owner, permissions, PHP extensions, Apache/Nginx rules, system cron jobs, queues, and supporting processes.
- Integrations: SMTP, payment gateway, inbound and outbound webhooks, forms, CRM, external storage, search, IP- or domain-bound licenses, and API allowlists.
- Network and names: the full DNS zone, A, AAAA, CNAME, MX, TXT, nameservers, authoritative provider, TTL, CDN/proxy, certificate, and DNSSEC/DS state.
- Changing data: orders, downloads, users, comments, sessions, bookings, leads, uploads, and any external system that writes back to WordPress.
This read-only inventory provides a reproducible baseline for a WP-CLI migration. Run it as the site owner rather than root, and replace the reserved sample values:
set -eu
SOURCE_PATH=/var/www/example.com/public
SOURCE_HOST=192.0.2.10
DEST_HOST=192.0.2.20
SITE_HOST=example.com
test -d "$SOURCE_PATH"
cd "$SOURCE_PATH"
wp core version
wp option get home
wp option get siteurl
wp config get table_prefix --type=variable
wp db tables --all-tables-with-prefix --format=csv
wp plugin list --format=table
wp theme list --format=table
wp cron event list --fields=hook,next_run_gmt,next_run_relative
wp db check
wp db size --human-readable
du -sh "$SOURCE_PATH"
df -h "$SOURCE_PATH"
printf 'Source=%s Destination=%s Site=%s\n' "$SOURCE_HOST" "$DEST_HOST" "$SITE_HOST"Export or capture settings that live outside WordPress, but keep secrets out of the runbook. Multisite, domain mapping, clusters, external databases, and high-write workloads require a dedicated procedure; this general workflow is not sufficient on its own.
Write the go/no-go rules before the window: enough free space, a restorable backup, compatible versions, a durable barrier rehearsed with an operator bypass, external rejection tests, independent monitoring, valid TLS, approved checkout and form tests, no unexplained errors, a DNS owner present, and time left to reverse the change. Set a deadline too: if a critical test is not fixed by the agreed time, postpone the cutover.
Build a restorable backup, not just an archive
A complete WordPress recovery needs both files and the database. The official WordPress backup guide treats them as separate data sets. Store backups outside the document root and, where possible, off the source server. A panel snapshot may add protection, but it does not replace a portable export and a restore test.
Set one unique, immutable UTC MIGRATION_ID for the entire run and never reuse a partial path. Before each export or tar, test ! -e prevents overwriting the only known-good copy. Also, wp db export exports every table in DB_NAME when --tables is omitted. This example obtains a CSV list with wp db tables --all-tables-with-prefix, displays it for review, and passes it quoted to --tables without word splitting:
set -eu
MIGRATION_ID=20260814T120000Z
SOURCE_PATH=/var/www/example.com/public
BACKUP_DIR="$HOME/migration-backups/example.com-$MIGRATION_ID"
DB_BACKUP="$BACKUP_DIR/database-baseline.sql"
FILES_BACKUP="$BACKUP_DIR/files-baseline.tar.gz"
umask 077
mkdir -p "$BACKUP_DIR"
test ! -e "$DB_BACKUP"
test ! -e "$FILES_BACKUP"
cd "$SOURCE_PATH"
wp db check
SOURCE_PREFIX=$(wp config get table_prefix --type=variable)
SOURCE_TABLES=$(wp db tables --all-tables-with-prefix --format=csv)
test -n "$SOURCE_PREFIX"
test -n "$SOURCE_TABLES"
printf 'Source prefix: %s\nProposed tables:\n' "$SOURCE_PREFIX"
printf '%s\n' "$SOURCE_TABLES" | tr ',' '\n'
printf 'Confirm the list contains exactly the required tables; type TABLE_SCOPE_APPROVED: '
read -r CONFIRM
test "$CONFIRM" = TABLE_SCOPE_APPROVED
wp db export "$DB_BACKUP" --tables="$SOURCE_TABLES" --add-drop-table
test -s "$DB_BACKUP"
tar -czf "$FILES_BACKUP" .
test -s "$FILES_BACKUP"
tar -tzf "$FILES_BACKUP" | head
sha256sum "$DB_BACKUP" "$FILES_BACKUP"
printf 'Confirm these are new nonempty artifacts outside the document root; type NEW_BACKUP_VERIFIED: '
read -r CONFIRM
test "$CONFIRM" = NEW_BACKUP_VERIFIEDIf the source shares DB_NAME, the explicit list avoids exporting unrelated tables by default, but you must identify plugin tables that do not follow the prefix. Multisite, shared or custom tables, and domain mapping require a dedicated runbook that enumerates every dependency; do not broaden scope blindly.
Record the checksums, MIGRATION_ID, prefix, approved list, date, versions, and scope. The meaningful test is to restore the artifacts into an isolated environment, open the application, and log the result and recovery time. A zero exit status alone does not prove that a backup can be restored.
Prepare the destination and make the initial copy
Set up the destination virtual host, PHP, a dedicated database and user, TLS, ownership, scheduled tasks, and capacity. Reconcile required wp-config.php constants without pasting credentials into tickets. If PHP, the database, or the web server changes, test compatibility before combining that change with cutover.
Verify the destination SSH fingerprint through an independent channel—such as the provider console, a signed inventory, or an authorized owner—before accepting the first connection. Do not trust a fingerprint obtained over the same path you are trying to authenticate. If source and destination use Redis, Memcached, or another persistent cache, give the clone isolated credentials or a separate namespace before testing. Never flush while both sites share a namespace.
Keep destination cron, workers, webhooks, and inbound/outbound mail disabled until coordinated activation. Payment and message tests must use a sandbox or controlled recipients so the clone cannot process duplicate jobs before live traffic arrives.
WP-CLI, a document-root rsync, and the WordPress database dump do not migrate mailboxes, aliases, forwarders, or message history. If mail lives on the old host, use a parallel runbook: inventory and provision mailboxes/aliases/forwarders, synchronize messages with a service-supported method, validate SMTP, IMAP, and real delivery, and reproduce SPF, DKIM, DMARC, PTR, and allowlists where applicable. Do not change MX or shut down the old mail service until those tests pass. Execution is service-specific; there is no safe universal command.
The initial sync runs while the source remains live. Run it as the site owner over verified SSH; it deliberately does not include --delete:
set -eu
SOURCE_PATH=/var/www/example.com/public
DEST_PATH=/var/www/example.com/public
DEST_HOST=192.0.2.20
DEST_USER=migrator
test -d "$SOURCE_PATH"
ssh "$DEST_USER@$DEST_HOST" "test -d '$DEST_PATH' && test -w '$DEST_PATH'"
rsync -aH --partial --itemize-changes \
--exclude='wp-config.php' \
--exclude='.maintenance' \
--exclude='wp-content/cache/' \
"$SOURCE_PATH/" "$DEST_USER@$DEST_HOST:$DEST_PATH/"Review the output and reconcile custom constants. Omitting --delete protects destination files, but it also preserves files removed at the source. Before go-live, compare both trees and delete only an approved list. If the team chooses rsync --delete, it needs a destination backup, identical exclusions, a reviewed --dry-run --itemize-changes, and an explicit gate before execution; the generic block does not perform it.
The initial wp db import flow keeps the same MIGRATION_ID, exports only the reviewed table list, and transfers the dump, manifest, and prefix. Before import, it compares $table_prefix using wp config get table_prefix --type=variable, displays every existing destination table, and requires the database to be dedicated, empty, or explicitly approved:
set -eu
MIGRATION_ID=20260814T120000Z
SOURCE_PATH=/var/www/example.com/public
DEST_PATH=/var/www/example.com/public
DEST_HOST=192.0.2.20
DEST_USER=migrator
SOURCE_RUN_DIR="$HOME/migration-backups/example.com-$MIGRATION_ID"
DEST_RUN_DIR="/home/$DEST_USER/migration-backups/example.com-$MIGRATION_ID"
LOCAL_DUMP="$SOURCE_RUN_DIR/database-initial-transfer.sql"
LOCAL_TABLES="$SOURCE_RUN_DIR/database-initial.tables.csv"
LOCAL_PREFIX="$SOURCE_RUN_DIR/database-initial.prefix"
REMOTE_DUMP="$DEST_RUN_DIR/database-initial-transfer.sql"
REMOTE_TABLES="$DEST_RUN_DIR/database-initial.tables.csv"
REMOTE_PREFIX="$DEST_RUN_DIR/database-initial.prefix"
PREIMPORT_BACKUP="$DEST_RUN_DIR/before-initial-import.sql"
EXPECTED_HOME=https://example.com
EXPECTED_SITEURL=https://example.com
umask 077
mkdir -p "$SOURCE_RUN_DIR"
test ! -e "$LOCAL_DUMP"
test ! -e "$LOCAL_TABLES"
test ! -e "$LOCAL_PREFIX"
cd "$SOURCE_PATH"
wp db check
SOURCE_PREFIX=$(wp config get table_prefix --type=variable)
SOURCE_TABLES=$(wp db tables --all-tables-with-prefix --format=csv)
test -n "$SOURCE_PREFIX"
test -n "$SOURCE_TABLES"
printf 'Source prefix: %s\nProposed tables:\n' "$SOURCE_PREFIX"
printf '%s\n' "$SOURCE_TABLES" | tr ',' '\n'
printf 'Confirm the exact site scope; type TABLE_SCOPE_APPROVED: '
read -r CONFIRM
test "$CONFIRM" = TABLE_SCOPE_APPROVED
printf '%s\n' "$SOURCE_TABLES" >"$LOCAL_TABLES"
printf '%s\n' "$SOURCE_PREFIX" >"$LOCAL_PREFIX"
wp db export "$LOCAL_DUMP" --tables="$SOURCE_TABLES" --add-drop-table
test -s "$LOCAL_DUMP"
test -s "$LOCAL_TABLES"
test -s "$LOCAL_PREFIX"
ssh "$DEST_USER@$DEST_HOST" bash -s -- \
"$DEST_RUN_DIR" "$REMOTE_DUMP" "$REMOTE_TABLES" "$REMOTE_PREFIX" <<'REMOTE'
set -eu
DEST_RUN_DIR=$1
REMOTE_DUMP=$2
REMOTE_TABLES=$3
REMOTE_PREFIX=$4
umask 077
mkdir -p "$DEST_RUN_DIR"
test ! -e "$REMOTE_DUMP"
test ! -e "$REMOTE_TABLES"
test ! -e "$REMOTE_PREFIX"
REMOTE
rsync -a --itemize-changes \
"$LOCAL_DUMP" "$LOCAL_TABLES" "$LOCAL_PREFIX" \
"$DEST_USER@$DEST_HOST:$DEST_RUN_DIR/"
DEST_PREFIX=$(
ssh "$DEST_USER@$DEST_HOST" bash -s -- "$DEST_PATH" <<'REMOTE'
set -eu
DEST_PATH=$1
cd "$DEST_PATH"
wp config get table_prefix --type=variable
REMOTE
)
DESTINATION_TABLES=$(
ssh "$DEST_USER@$DEST_HOST" bash -s -- "$DEST_PATH" <<'REMOTE'
set -eu
DEST_PATH=$1
cd "$DEST_PATH"
wp db tables --all-tables --format=csv
REMOTE
)
test -n "$DEST_PREFIX"
printf 'Source prefix=%s destination=%s\nCurrent destination tables=%s\n' \
"$SOURCE_PREFIX" "$DEST_PREFIX" "$DESTINATION_TABLES"
test "$SOURCE_PREFIX" = "$DEST_PREFIX"
printf 'Confirm the destination DB is dedicated and empty/approved; type DESTINATION_DB_APPROVED: '
read -r CONFIRM
test "$CONFIRM" = DESTINATION_DB_APPROVED
printf 'Type IMPORT_INITIAL to create a new backup and import: '
read -r CONFIRM
test "$CONFIRM" = IMPORT_INITIAL
ssh "$DEST_USER@$DEST_HOST" bash -s -- \
"$DEST_PATH" "$REMOTE_DUMP" "$REMOTE_TABLES" "$REMOTE_PREFIX" \
"$PREIMPORT_BACKUP" "$EXPECTED_HOME" "$EXPECTED_SITEURL" <<'REMOTE'
set -eu
DEST_PATH=$1
INCOMING_DUMP=$2
TABLE_MANIFEST=$3
PREFIX_MANIFEST=$4
PREIMPORT_BACKUP=$5
EXPECTED_HOME=$6
EXPECTED_SITEURL=$7
cd "$DEST_PATH"
umask 077
test -s "$INCOMING_DUMP"
test -s "$TABLE_MANIFEST"
test -s "$PREFIX_MANIFEST"
EXPECTED_TABLES=$(cat "$TABLE_MANIFEST")
EXPECTED_PREFIX=$(cat "$PREFIX_MANIFEST")
test -n "$EXPECTED_TABLES"
test -n "$EXPECTED_PREFIX"
test "$(wp config get table_prefix --type=variable)" = "$EXPECTED_PREFIX"
test ! -e "$PREIMPORT_BACKUP"
wp db export "$PREIMPORT_BACKUP" --add-drop-table
test -s "$PREIMPORT_BACKUP"
wp db import "$INCOMING_DUMP"
wp db check
EXPECTED_SORTED=$(printf '%s\n' "$EXPECTED_TABLES" | tr ',' '\n' | LC_ALL=C sort)
ACTUAL_SORTED=$(wp db tables --all-tables-with-prefix --format=csv | tr ',' '\n' | LC_ALL=C sort)
test "$ACTUAL_SORTED" = "$EXPECTED_SORTED"
HOME_URL=$(wp option get home)
SITE_URL=$(wp option get siteurl)
printf 'home=%s\nsiteurl=%s\n' "$HOME_URL" "$SITE_URL"
test "$HOME_URL" = "$EXPECTED_HOME"
test "$SITE_URL" = "$EXPECTED_SITEURL"
REMOTE--add-drop-table only adds DROP TABLE for tables included in the dump; wp db import does not remove extras. The destination therefore needs a dedicated empty/approved database or a separately inventoried cleanup with its own backup and gate. Post-import checks require exactly the expected prefixed tables and validate home/siteurl. If transfer, backup, prefix, scope, import, or verification fails, DNS does not change.
Run search-replace only when a URL changes
If the final domain and protocol stay the same, you normally do not need a URL replacement. If you used a temporary hostname, wp search-replace understands serialized data; a blanket SQL REPLACE() does not. Skip guid, review a --dry-run, and create a new non-overwriting backup before applying changes:
set -eu
MIGRATION_ID=20260814T120000Z
DEST_PATH=/var/www/example.com/public
BACKUP_DIR="$HOME/migration-backups/example.com-$MIGRATION_ID"
PRE_REPLACE_BACKUP="$BACKUP_DIR/before-search-replace.sql"
OLD_URL=https://temporary.example.com
NEW_URL=https://example.com
test "$OLD_URL" != "$NEW_URL"
umask 077
mkdir -p "$BACKUP_DIR"
test ! -e "$PRE_REPLACE_BACKUP"
cd "$DEST_PATH"
wp search-replace "$OLD_URL" "$NEW_URL" \
--all-tables-with-prefix --precise --skip-columns=guid --dry-run
printf 'Review the dry run and type APPLY_REPLACE: '
read -r CONFIRM
test "$CONFIRM" = APPLY_REPLACE
wp db export "$PRE_REPLACE_BACKUP" --add-drop-table
test -s "$PRE_REPLACE_BACKUP"
wp search-replace "$OLD_URL" "$NEW_URL" \
--all-tables-with-prefix --precise --skip-columns=guid --report-changed-only
wp db checkReview plugin tables outside the prefix before extending scope. Do not replace strings you do not understand or accidentally rewrite email domains, keys, and third-party data.
Test the new server before changing DNS
curl --resolve maps the real hostname to a chosen IP while public DNS stays unchanged. --fail-with-body requires curl 7.76 or newer; on an older release use --fail and retain every other control. Never add -k: a TLS error is a cutover blocker.
Move Your WordPress Site to Hosting Ready to Grow
Prepare the destination, validate the site before cutover, and keep a clear rollback path.


set -eu
SITE_HOST=example.com
DEST_HOST=192.0.2.20
curl --fail-with-body --silent --show-error \
--resolve "$SITE_HOST:443:$DEST_HOST" \
--output /dev/null --write-out 'home=%{http_code}\n' \
"https://$SITE_HOST/"
curl --fail-with-body --silent --show-error \
--resolve "$SITE_HOST:443:$DEST_HOST" \
--output /dev/null --write-out 'login=%{http_code}\n' \
"https://$SITE_HOST/wp-login.php"
curl --silent --show-error --head \
--resolve "$SITE_HOST:443:$DEST_HOST" \
"https://$SITE_HOST/"For browser testing, you can add a temporary entry to your workstation's hosts file, but document and remove it afterward. On the destination, test:
- Home, content pages, search, redirects, 404 handling, and the dashboard.
- Sign-in and sign-out, roles, a controlled content update, and a disposable image upload.
- Forms, cart, sandbox checkout, tax, download, membership, or booking flows as applicable.
- Actual email receipt—not merely an “email sent” notice—plus inbound/outbound webhooks, cron, queues, and payment callbacks.
- HTTPS, mixed content, headers, page/object caching, CDN behavior, and purge controls.
- Private PHP, web, and database logs plus CPU, memory, disk, and inode use.
Compare URLs, canonical tags, robots directives, sitemap, structured data, analytics, and HTTP status codes with the source. Do not combine a no-URL-change hosting migration with a redesign or information-architecture change.
Control writes and apply the final delta
The initial copy is stale as soon as another order or upload arrives. Identify visitors, editors, WP-Cron, system cron, workers, queues, webhooks, importers, and APIs. Pause each writer reversibly and record how you will prove that it stopped changing data.
The standard .maintenance mode is not a durable barrier. WordPress core's wp_is_maintenance_mode() reads its timestamp and stops treating the standard mode as active after roughly ten minutes. WP-CLI can manage it as maintenance UX, but it must not protect orders or forms during a long migration.
Before the window, choose and rehearse a non-expiring write barrier that remains until explicitly removed at the edge/CDN, web server, application, or provider control layer. It must cover source and destination; reject checkout, forms, registrations, comments, wp-admin writes, and mutating REST/API calls; and allow operator access through an authenticated bypass or controlled allowlist. There is no safe universal command.
From an external network without the bypass, run negative tests and confirm that no route creates a record. Keep an independent monitor running during delta, export, import, and DNS. If a write stops being rejected or monitoring loses signal, abort, restore the barrier, account for changes since the last checkpoint, and repeat delta/import.
This block requires the barrier and paused writers, explicitly reconciles deleted files, and exports exactly the reviewed table list. Its MIGRATION_ID must match the rest of the run:
set -eu
MIGRATION_ID=20260814T120000Z
SOURCE_PATH=/var/www/example.com/public
SOURCE_RUN_DIR="$HOME/migration-backups/example.com-$MIGRATION_ID"
DEST_PATH=/var/www/example.com/public
DEST_HOST=192.0.2.20
DEST_USER=migrator
FINAL_DUMP="$SOURCE_RUN_DIR/database-final.sql"
FINAL_TABLES="$SOURCE_RUN_DIR/database-final.tables.csv"
FINAL_PREFIX="$SOURCE_RUN_DIR/database-final.prefix"
umask 077
mkdir -p "$SOURCE_RUN_DIR"
test ! -e "$FINAL_DUMP"
test ! -e "$FINAL_TABLES"
test ! -e "$FINAL_PREFIX"
cd "$SOURCE_PATH"
wp db check
printf 'Apply the rehearsed non-expiring barrier to source and destination; type DURABLE_BARRIER_VERIFIED: '
read -r CONFIRM
test "$CONFIRM" = DURABLE_BARRIER_VERIFIED
printf 'After external checkout, form, admin, and API tests, type EXTERNAL_WRITES_BLOCKED: '
read -r CONFIRM
test "$CONFIRM" = EXTERNAL_WRITES_BLOCKED
printf 'Pause cron, workers, queues, webhooks, admin, and APIs; type ALL_WRITERS_PAUSED: '
read -r CONFIRM
test "$CONFIRM" = ALL_WRITERS_PAUSED
SOURCE_PREFIX=$(wp config get table_prefix --type=variable)
SOURCE_TABLES=$(wp db tables --all-tables-with-prefix --format=csv)
test -n "$SOURCE_PREFIX"
test -n "$SOURCE_TABLES"
printf 'Prefix=%s\nProposed final tables:\n' "$SOURCE_PREFIX"
printf '%s\n' "$SOURCE_TABLES" | tr ',' '\n'
printf 'Confirm the exact scope; type FINAL_SCOPE_APPROVED: '
read -r CONFIRM
test "$CONFIRM" = FINAL_SCOPE_APPROVED
rsync -aH --partial --itemize-changes \
--exclude='wp-config.php' \
--exclude='.maintenance' \
--exclude='wp-content/cache/' \
"$SOURCE_PATH/" "$DEST_USER@$DEST_HOST:$DEST_PATH/"
printf 'Compare trees and resolve only the approved deletion list; type DELETIONS_RECONCILED: '
read -r CONFIRM
test "$CONFIRM" = DELETIONS_RECONCILED
printf 'Confirm the barrier monitor is healthy; type BARRIER_STILL_ACTIVE: '
read -r CONFIRM
test "$CONFIRM" = BARRIER_STILL_ACTIVE
printf '%s\n' "$SOURCE_TABLES" >"$FINAL_TABLES"
printf '%s\n' "$SOURCE_PREFIX" >"$FINAL_PREFIX"
wp db export "$FINAL_DUMP" --tables="$SOURCE_TABLES" --add-drop-table
test -s "$FINAL_DUMP"
test -s "$FINAL_TABLES"
test -s "$FINAL_PREFIX"
sha256sum "$FINAL_DUMP"
printf 'Confirm the dump and manifests are new and nonempty; type FINAL_ARTIFACTS_VERIFIED: '
read -r CONFIRM
test "$CONFIRM" = FINAL_ARTIFACTS_VERIFIEDTransfer the dump and manifests. Compare SHA-256 before any import gate; compare prefixes, inventory every destination table, and require approval. Cache flush is authorized only after confirming an isolated namespace:
set -eu
MIGRATION_ID=20260814T120000Z
SOURCE_PATH=/var/www/example.com/public
DEST_PATH=/var/www/example.com/public
DEST_HOST=192.0.2.20
DEST_USER=migrator
SOURCE_RUN_DIR="$HOME/migration-backups/example.com-$MIGRATION_ID"
DEST_RUN_DIR="/home/$DEST_USER/migration-backups/example.com-$MIGRATION_ID"
LOCAL_DUMP="$SOURCE_RUN_DIR/database-final.sql"
LOCAL_TABLES="$SOURCE_RUN_DIR/database-final.tables.csv"
LOCAL_PREFIX="$SOURCE_RUN_DIR/database-final.prefix"
REMOTE_DUMP="$DEST_RUN_DIR/database-final.sql"
REMOTE_TABLES="$DEST_RUN_DIR/database-final.tables.csv"
REMOTE_PREFIX="$DEST_RUN_DIR/database-final.prefix"
PREIMPORT_BACKUP="$DEST_RUN_DIR/before-final-import.sql"
EXPECTED_HOME=https://example.com
EXPECTED_SITEURL=https://example.com
test -s "$LOCAL_DUMP"
test -s "$LOCAL_TABLES"
test -s "$LOCAL_PREFIX"
ssh "$DEST_USER@$DEST_HOST" bash -s -- \
"$DEST_RUN_DIR" "$REMOTE_DUMP" "$REMOTE_TABLES" "$REMOTE_PREFIX" <<'REMOTE'
set -eu
DEST_RUN_DIR=$1
REMOTE_DUMP=$2
REMOTE_TABLES=$3
REMOTE_PREFIX=$4
umask 077
mkdir -p "$DEST_RUN_DIR"
test ! -e "$REMOTE_DUMP"
test ! -e "$REMOTE_TABLES"
test ! -e "$REMOTE_PREFIX"
REMOTE
rsync -a --itemize-changes \
"$LOCAL_DUMP" "$LOCAL_TABLES" "$LOCAL_PREFIX" \
"$DEST_USER@$DEST_HOST:$DEST_RUN_DIR/"
LOCAL_SHA256=$(sha256sum "$LOCAL_DUMP" | awk '{print $1}')
REMOTE_SHA256=$(
ssh "$DEST_USER@$DEST_HOST" bash -s -- "$REMOTE_DUMP" <<'REMOTE'
set -eu
REMOTE_DUMP=$1
test -s "$REMOTE_DUMP"
sha256sum "$REMOTE_DUMP" | awk '{print $1}'
REMOTE
)
test -n "$LOCAL_SHA256"
test -n "$REMOTE_SHA256"
printf 'local=%s remote=%s\n' "$LOCAL_SHA256" "$REMOTE_SHA256"
test "$LOCAL_SHA256" = "$REMOTE_SHA256"
cd "$SOURCE_PATH"
SOURCE_PREFIX=$(cat "$LOCAL_PREFIX")
DEST_PREFIX=$(
ssh "$DEST_USER@$DEST_HOST" bash -s -- "$DEST_PATH" <<'REMOTE'
set -eu
DEST_PATH=$1
cd "$DEST_PATH"
wp config get table_prefix --type=variable
REMOTE
)
DESTINATION_TABLES=$(
ssh "$DEST_USER@$DEST_HOST" bash -s -- "$DEST_PATH" <<'REMOTE'
set -eu
DEST_PATH=$1
cd "$DEST_PATH"
wp db tables --all-tables --format=csv
REMOTE
)
test -n "$SOURCE_PREFIX"
test -n "$DEST_PREFIX"
printf 'Source prefix=%s destination=%s\nCurrent destination tables=%s\n' \
"$SOURCE_PREFIX" "$DEST_PREFIX" "$DESTINATION_TABLES"
test "$SOURCE_PREFIX" = "$DEST_PREFIX"
printf 'Confirm a dedicated destination DB with approved contents; type DESTINATION_DB_APPROVED: '
read -r CONFIRM
test "$CONFIRM" = DESTINATION_DB_APPROVED
printf 'Confirm the barrier monitor is healthy; type BARRIER_STILL_ACTIVE: '
read -r CONFIRM
test "$CONFIRM" = BARRIER_STILL_ACTIVE
printf 'Confirm an isolated destination cache namespace; type DESTINATION_CACHE_ISOLATED: '
read -r CONFIRM
test "$CONFIRM" = DESTINATION_CACHE_ISOLATED
printf 'Confirm writers remain paused; type IMPORT_FINAL: '
read -r CONFIRM
test "$CONFIRM" = IMPORT_FINAL
ssh "$DEST_USER@$DEST_HOST" bash -s -- \
"$DEST_PATH" "$REMOTE_DUMP" "$REMOTE_TABLES" "$REMOTE_PREFIX" \
"$PREIMPORT_BACKUP" "$EXPECTED_HOME" "$EXPECTED_SITEURL" <<'REMOTE'
set -eu
DEST_PATH=$1
INCOMING_DUMP=$2
TABLE_MANIFEST=$3
PREFIX_MANIFEST=$4
PREIMPORT_BACKUP=$5
EXPECTED_HOME=$6
EXPECTED_SITEURL=$7
cd "$DEST_PATH"
umask 077
test -s "$INCOMING_DUMP"
test -s "$TABLE_MANIFEST"
test -s "$PREFIX_MANIFEST"
EXPECTED_TABLES=$(cat "$TABLE_MANIFEST")
EXPECTED_PREFIX=$(cat "$PREFIX_MANIFEST")
test -n "$EXPECTED_TABLES"
test -n "$EXPECTED_PREFIX"
test "$(wp config get table_prefix --type=variable)" = "$EXPECTED_PREFIX"
test ! -e "$PREIMPORT_BACKUP"
wp db export "$PREIMPORT_BACKUP" --add-drop-table
test -s "$PREIMPORT_BACKUP"
wp db import "$INCOMING_DUMP"
wp db check
EXPECTED_SORTED=$(printf '%s\n' "$EXPECTED_TABLES" | tr ',' '\n' | LC_ALL=C sort)
ACTUAL_SORTED=$(wp db tables --all-tables-with-prefix --format=csv | tr ',' '\n' | LC_ALL=C sort)
test "$ACTUAL_SORTED" = "$EXPECTED_SORTED"
HOME_URL=$(wp option get home)
SITE_URL=$(wp option get siteurl)
printf 'home=%s\nsiteurl=%s\n' "$HOME_URL" "$SITE_URL"
test "$HOME_URL" = "$EXPECTED_HOME"
test "$SITE_URL" = "$EXPECTED_SITEURL"
wp cache flush
REMOTE
printf 'Confirm no write was accepted and the barrier held through import; type NO_WRITES_DURING_IMPORT: '
read -r CONFIRM
test "$CONFIRM" = NO_WRITES_DURING_IMPORT--add-drop-table does not remove extra tables; it only replaces included ones. Do not change DNS if checksum, new backup, prefix, table inventory, URLs, isolated cache, deletion reconciliation, or barrier monitoring fails. Repeat critical tests through the controlled bypass; the durable barrier still covers both servers.
Reduce DNS cutover risk
Lower the TTL before migration, then wait at least the previous TTL before relying on the lower value. Cloudflare's DNS migration preparation guidance describes this timing. A shorter TTL affects future cache entries; it does not erase cached answers or guarantee a single global switch instant.
If the authoritative DNS provider remains the same, you usually need to change A and, if published, AAAA. A stale AAAA can keep sending IPv6 visitors to the old host after A changes. A nameserver change is broader: reproduce the full zone—including MX, TXT, and validation records—and coordinate DNSSEC. A DS record at the registrar that does not match the new zone's keys can cause SERVFAIL. Review how DNS records and TTL work before changing delegation.
Cutover is go only when backups and rollback are available; the durable barrier covers source and destination and its monitor is healthy; every writer is paused; checksum and import passed; destination, TLS, and critical integrations are approved; A and AAAA are accounted for; TTL was prepared; DNSSEC and mail are inventoried; and the required owners are present. Change only the planned records, save the previous values, and record the time.
set -eu
SITE_HOST=example.com
SOURCE_HOST=192.0.2.10
DEST_HOST=192.0.2.20
printf 'Expected old=%s new=%s\n' "$SOURCE_HOST" "$DEST_HOST"
dig +short A "$SITE_HOST"
dig +short AAAA "$SITE_HOST"
curl --silent --show-error --output /dev/null \
--write-out 'source=%{http_code}\n' \
--resolve "$SITE_HOST:443:$SOURCE_HOST" "https://$SITE_HOST/"
curl --silent --show-error --output /dev/null \
--write-out 'destination=%{http_code}\n' \
--resolve "$SITE_HOST:443:$DEST_HOST" "https://$SITE_HOST/"Repeat checks from more than one resolver and network. Your workstation's answer does not represent every visitor. Keep the barrier on both hosts while caches return mixed answers; a request reaching the source must not create a divergent data branch.
Activate destination processes exactly once
When DNS and observed traffic point to the destination, keep public writes closed. Using the stack's rehearsed runbook, activate exactly one instance of cron, workers, queue consumers, webhooks, and inbound/outbound mail on the destination. Verify the scheduler heartbeat, lock owner, consumption of a test queue item, one idempotent webhook, and controlled mail delivery; at the same time, confirm that these processes remain stopped on the source.
Only after that verification should you remove the destination barrier through the approved control, keep the source barrier in place, and run a traceable transactional canary. This block coordinates gates; it does not claim to know your provider, supervisor, or application commands:
set -eu
DEST_PATH=/var/www/example.com/public
DEST_HOST=192.0.2.20
SITE_HOST=example.com
cd "$DEST_PATH"
wp db check
printf 'Confirm the durable barrier covers both hosts and source writers are paused; type CUTOVER_BARRIER_ACTIVE: '
read -r CONFIRM
test "$CONFIRM" = CUTOVER_BARRIER_ACTIVE
printf 'Use the rehearsed runbook to activate one destination instance of cron, workers, queues, webhooks and inbound/outbound mail; type DEST_WRITERS_ONE: '
read -r CONFIRM
test "$CONFIRM" = DEST_WRITERS_ONE
printf 'Verify heartbeats, locks, queues, a webhook and mail with no duplicate; type DEST_WRITERS_VERIFIED: '
read -r CONFIRM
test "$CONFIRM" = DEST_WRITERS_VERIFIED
printf 'From outside, confirm write routes remain blocked; type NEGATIVE_TESTS_PASS: '
read -r CONFIRM
test "$CONFIRM" = NEGATIVE_TESTS_PASS
printf 'Remove only the destination barrier and keep the source blocked; type DEST_WRITES_OPEN: '
read -r CONFIRM
test "$CONFIRM" = DEST_WRITES_OPEN
curl --fail-with-body --silent --show-error \
--resolve "$SITE_HOST:443:$DEST_HOST" \
--output /dev/null "https://$SITE_HOST/"
printf 'Complete one traceable write canary and verify a single result; type DEST_CUTOVER_VERIFIED: '
read -r CONFIRM
test "$CONFIRM" = DEST_CUTOVER_VERIFIEDMonitor the move and verify SEO
During the observation window, compare traffic and logs on both hosts, 5xx rate, latency, PHP errors, database connections, queues, cron, webhooks, email, orders, and form submissions. Keep an unambiguous way to identify which server replied, such as internal logs or a non-cached diagnostic header that reveals no sensitive data.
Google's guidance for moving to new hosting without URL changes recommends preparing and testing the new infrastructure, updating DNS, and monitoring traffic. Confirm that URLs retain their status codes, canonical tags, robots directives, and sitemap; remove any staging noindex; and verify that CDN caches are not serving errors. Do not use a change-of-address tool when the domain stays the same.
Once stable, compare performance and capacity with your baseline. If the site regresses, use a controlled checklist such as these checks for speeding up a slow WordPress site. Avoid enabling several optimizations during cutover, because that obscures both the cause of a failure and the rollback path.
Design rollback around post-cutover writes
Trigger rollback against agreed thresholds: broken checkout/sign-in, sustained 5xx responses, database corruption or connection failure, invalid TLS, unavailable critical integrations, or a data discrepancy that cannot be corrected before the deadline.
Always, even if you believe the destination received no writes, first apply or confirm its durable barrier and pause every destination writer before evaluating logs, capturing a delta, or reverting DNS. Keep the destination closed for clients holding cached DNS. If it accepted orders, registrations, or forms, capture and reconcile that delta with a tested process before restoring DNS; pointing back without reconciliation would lose data.
Collect timestamped evidence of the destination barrier, stopped writers, reviewed ID/log range, reconciliation, and restored DNS. SOURCE_DATA_SAFE is valid only after those artifacts exist and are reviewed. With the source barrier still active, validate its database and application through the bypass; reactivate exactly one instance of cron, workers, queues, webhooks, and inbound/outbound mail on the source; prove zero remain on the destination; and only then open the source. Keep the destination blocked until the incident closes.
set -eu
MIGRATION_ID=20260814T120000Z
SOURCE_PATH=/var/www/example.com/public
SOURCE_HOST=192.0.2.10
SITE_HOST=example.com
EVIDENCE_DIR="$HOME/migration-evidence/example.com-$MIGRATION_ID"
DEST_FREEZE_EVIDENCE="$EVIDENCE_DIR/destination-barrier-and-writers.txt"
RECONCILIATION_EVIDENCE="$EVIDENCE_DIR/reconciliation-report.txt"
DNS_EVIDENCE="$EVIDENCE_DIR/dns-restored-to-source.txt"
cd "$SOURCE_PATH"
wp db check
printf 'Apply the durable barrier to the destination and pause its writers; type DESTINATION_FROZEN: '
read -r CONFIRM
test "$CONFIRM" = DESTINATION_FROZEN
test -s "$DEST_FREEZE_EVIDENCE"
test -s "$RECONCILIATION_EVIDENCE"
test -s "$DNS_EVIDENCE"
printf 'After reviewing freeze, reconciliation, and DNS evidence, type SOURCE_DATA_SAFE: '
read -r CONFIRM
test "$CONFIRM" = SOURCE_DATA_SAFE
printf 'Confirm the source barrier blocks external writes; type SOURCE_BARRIER_ACTIVE: '
read -r CONFIRM
test "$CONFIRM" = SOURCE_BARRIER_ACTIVE
printf 'Validate the source through operator access; type SOURCE_BYPASS_VALIDATED: '
read -r CONFIRM
test "$CONFIRM" = SOURCE_BYPASS_VALIDATED
printf 'Activate exactly one source instance of cron, workers, queues, webhooks, and inbound/outbound mail; type SOURCE_WRITERS_ONE: '
read -r CONFIRM
test "$CONFIRM" = SOURCE_WRITERS_ONE
printf 'Verify source writers=1 and destination writers=0; type ROLLBACK_WRITERS_VERIFIED: '
read -r CONFIRM
test "$CONFIRM" = ROLLBACK_WRITERS_VERIFIED
printf 'Remove the source barrier and keep the destination blocked; type SOURCE_WRITES_OPEN: '
read -r CONFIRM
test "$CONFIRM" = SOURCE_WRITES_OPEN
curl --fail-with-body --silent --show-error \
--resolve "$SITE_HOST:443:$SOURCE_HOST" \
--output /dev/null "https://$SITE_HOST/"
printf 'Complete one traceable source write canary; type SOURCE_ROLLBACK_VERIFIED: '
read -r CONFIRM
test "$CONFIRM" = SOURCE_ROLLBACK_VERIFIEDRecord who approved the return, reconciled data, evidence, canaries, and clients that may have reached either host. Preserve backups, logs, and the failed destination for analysis rather than overwriting evidence.
Troubleshoot common WordPress migration failures
Safe first checks after moving WordPress
| Symptom | Check first | Safe response |
|---|---|---|
| Database connection error | Database name, user, host, privileges, socket/port, and effective wp-config.php values. | Compare them with the dedicated database and run wp db check; never put the password in the command. |
| 500 error or blank page | Private logs, PHP version/extensions, memory, mu-plugins, and the last change. | Reverse one change at a time or restore a known-good set; do not expose errors publicly. |
| Redirect loop | home, siteurl, proxy/CDN HTTPS detection, and duplicate rules. | Make one layer responsible for redirects and test source and destination separately. |
| Mixed content | HTTP URLs in HTML, CSS, widgets, and options. | Locate the source and use serialized-aware search-replace with a dry run; do not disable TLS. |
| 404 on internal pages | Permalinks, .htaccess or Nginx rules, and document root. | Regenerate WordPress rules or restore the reviewed web-server configuration. |
| Uploads or updates cannot write | The PHP owner, group, ACL, and expected writable paths. | Restore the hosting model's ownership and permissions; never use chmod 777. |
| Invalid certificate | SAN coverage for the domain/www, chain, SNI, proxy/CDN, and A/AAAA. | Fix issuance and routing; do not use curl -k or disable verification. |
| Email or webhooks fail | SMTP, mail DNS, IP allowlists, secrets, callback URLs, cron, and third-party logs. | Send a traceable test and replay only idempotent events; do not duplicate charges or messages. |
| Old content still appears | DNS, CDN, page/object/browser caches, and the IP that answered. | Identify the cache layer before purging and test each host directly with --resolve. |
| The barrier accepts a write | External checkout/form/admin/API probes, monitor health, and actual coverage on both hosts. | Abort cutover, restore the barrier, account for changes since the last checkpoint, and repeat delta/import; do not open the destination. |
Keep the old host until the observation window closes
Do not delete the source after the first successful response from the destination. Wait through an observation period based on the previous TTL, business cycle, and delayed integrations. Confirm that the source no longer receives useful traffic or callbacks, destination backups succeed, a restore is scheduled or proven, and application and SEO metrics remain stable.
Then revoke temporary access, remove workstation hosts entries, update allowlists and documentation, resume cron and workers in exactly one place, and restore the intended operating TTL through a controlled, verified change. Retain backups under policy. Decommission the source only with approval and evidence that it does not hold the sole copy of recent data.
Frequently asked questions
How long does a WordPress hosting migration take?
It depends on site size, transfer speed, testing, and the write window. The initial copy can run while the site is live; the sensitive period is the final delta, validation, and cutover. Measure it with a rehearsal instead of relying on a generic estimate.
Does a migration plugin prevent lost orders?
Not by itself. A package represents one point in time. You must block or synchronize later orders, compare the latest record identifiers, and test payments, callbacks, and email before opening the destination.
Do I need to change nameservers?
No if you keep the current authoritative DNS provider; changing A/AAAA is usually enough. Nameserver changes add risk because the full zone, mail records, and DNSSEC must move correctly.
Can I migrate multisite with this procedure?
Use it only as a framework. Multisite, subdomains, domain mapping, large networks, and custom tables need tailored inventory, replacements, and tests. Build a dedicated runbook.
A safe migration ends after cutover, not at it
Migrating WordPress without losing data is about controlling state, not merely copying folders. Choose the method, inventory every writer, test a restore, prepare the destination, perform an initial copy and a controlled final delta, validate by hostname, cut DNS, and observe. Keep the old host until the application, integrations, backups, and SEO are demonstrably healthy on the new one.








