Migrating from a Single Server
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
This page was written against WordPress 7.1.3 (7.1.4, the security release of 2026-10-28, is the version to run in
production), WP-CLI 2.12.0, PHP 8.4, RDS for MariaDB 11.8 and RDS for MySQL 8.4 as targets, and Kubernetes 1.36 and 1.37 on
Amazon EKS. It was checked on 2026-10-29. The shell scripts were checked with shellcheck, the JSON documents with a JSON
parser and the Kubernetes manifest with kubeconform. Nothing was run against a real AWS account or a real production
server, so every command that touches AWS or the old server is shown for you to adapt and rehearse; the Bitnami and cPanel
file locations are the typical ones and must be confirmed on your machine.
The starting point is the classic installation described on
Installation and
Requirements and Hosting: one machine that runs the web server, PHP and
the database, keeps wp-content/uploads on its own disk and runs a system cron. The destination is the tiered
deployment of the other pages of the Cloud Deployment section: Lumen Press at lumenpress.example on the EKS cluster
lumenpress-prod in eu-west-1 (account 123456789012), database in RDS (lumenpress-prod, database lumenpress, user
wp_app), media in the S3 bucket lumenpress-prod-media or on EFS, cache in ElastiCache for Valkey. The reasons for that
architecture and the state table are in Cloud Reference Architecture;
this page is the road between the two. Moving a site between WordPress installations in general (export files, Playground,
host to host) is on Importing, Exporting and Migrating.
The shape of a migration
A migration has three pieces of state to move (database, media, configuration) and one moment to choose (the cutover). Everything else is rehearsal. The safest order is:
-
Inventory the old server: what actually runs, not what the documentation says.
-
Build the target from code: the chart, the image, the empty RDS instance, the bucket. Nothing here touches the old site.
-
Rehearse with a copy: export the database, import it, copy the media, run
wp search-replaceif the address changes, boot the new Pods and compare. Repeat until it is boring, and time each step. -
Cut over: freeze writes on the old server, move the last delta, switch DNS, verify.
-
Keep the old server for a defined period as the rollback.
Inventory of the old server
Most failed migrations fail on something that nobody remembered: a cron entry, a PHP setting, a drop-in, an email that leaves through the local mail server. The inventory below runs on the old server and writes a report that the rest of the page refers to.
#!/usr/bin/env bash
# Run on the old server as the user that owns the WordPress files.
set -euo pipefail
WP_PATH="${WP_PATH:-/var/www/html}"
wp() { command wp --path="$WP_PATH" "$@"; }
echo "== Core, PHP and database"
wp core version --extra
php -v | head -n 1
wp db version 2>/dev/null || true
wp db query "SELECT @@version, @@sql_mode, @@character_set_database, @@collation_database" --skip-column-names
echo "== Tables and engines other than InnoDB"
wp db query "SELECT table_name, engine FROM information_schema.tables WHERE table_schema = DATABASE() AND engine <> 'InnoDB'"
echo "== Size of the database and of the uploads"
wp db size --size_format=mb --tables
du -sh "$WP_PATH/wp-content/uploads"
find "$WP_PATH/wp-content/uploads" -type f | wc -l
echo "== Site address and multisite"
wp option get siteurl
wp option get home
wp eval 'echo is_multisite() ? "multisite\n" : "single site\n";'
echo "== Plugins, themes, must-use plugins and drop-ins"
wp plugin list --fields=name,status,version,update
wp theme list --fields=name,status,version
wp plugin list --status=must-use --fields=name,version
ls -l "$WP_PATH"/wp-content/*.php || true
echo "== Constants defined in wp-config.php"
wp config list --fields=name,type
echo "== Scheduled work"
wp cron event list --fields=hook,next_run_relative,recurrence
crontab -l 2>/dev/null || true
ls /etc/cron.d 2>/dev/null || true
echo "== Server-side files that WordPress does not know about"
for f in .htaccess .user.ini php.ini .maintenance; do
[ -e "$WP_PATH/$f" ] && echo "present: $f"
done
true
Source: WP-CLI Commands: wp config list
Read the report for the following, because each one changes the plan:
-
Non-InnoDB tables. MyISAM tables from old installations do not survive RDS Multi-AZ failover well and RDS expects InnoDB. Convert them on the old server first (
ALTER TABLE ... ENGINE=InnoDB), where a failure is cheap. -
The database server version. A MySQL 5.7 or MariaDB 10.x source goes through a logical dump into a current major without an in-place upgrade. Check
sql_modeand the collation: WordPress usesutf8mb4and, for MariaDB 11.8, the default collation of the server differs from older ones, so the dump carries explicitCHARSETandCOLLATEclauses. -
Drop-ins (
advanced-cache.php,object-cache.php,db.php,maintenance.php). A page-cache or object-cache drop-in written for a local disk or Memcached on the same machine must be removed or replaced (Object Cache, Sessions and Cron in a Cluster). -
Plugins that write to the file system: backup plugins (they fill
wp-contentwith archives), security plugins that addauto_prepend_filerules or write a WAF file, caching plugins, form plugins that store uploads. Replace them or configure an off-box store. A page that offers an "export to the server" button cannot work with a read-only image. -
The crontab. A line that calls
wp-cron.phpover HTTP orwp cron event runbecomes the CronJoblumenpress-cron; any other line (a script that exports data, a certificate renewal) is a separate decision. -
Mail. PHP
mail()through the local MTA does not exist in a container. Move to SES through SMTP or an API plugin (Email Delivery, Amazon SES) before the cutover and test deliverability from staging. -
.htaccessand PHP settings. Rewrites, redirects, headers,upload_max_filesizeandmemory_limitset there move to the Nginx configuration and the PHP ini of the image (Web Servers: Apache and Nginx). -
A multisite network or a WooCommerce shop. A network has extra tables,
wp_blogsandwp_sitedomains thatwp search-replace --networkmust handle, and a shop writes orders continuously, which makes the cutover window the hard part; see Cloud: Migrating a Store from a Single Server for the shop-specific steps.
Moving the database
Whichever way the data moves, the target is a database that already exists in RDS with the right character set, the users
of Managed Database: RDS and Aurora (wp_app for the application,
wp_migrate for the import, with the second removed or locked afterwards) and TLS enforced; the RDS instance is in private
subnets, so the import runs from a bastion, an SSM-reachable instance or a Pod inside the VPC, never from a laptop over a
public endpoint.
Dump and import
For a database of a few gigabytes and a cutover window of an hour or two, a logical dump is the simplest and most predictable method.
#!/usr/bin/env bash
set -euo pipefail
WP_PATH="${WP_PATH:-/var/www/html}"
OUT="${OUT:-/tmp/lumenpress-$(date +%Y%m%d-%H%M).sql}"
# --single-transaction gives a consistent InnoDB snapshot without locking the site.
# DEFINER clauses (views, triggers) refer to users that do not exist on RDS, so remove them.
# shellcheck disable=SC2016 # the backticks belong to the sed expression
wp --path="$WP_PATH" db export - \
--single-transaction \
--default-character-set=utf8mb4 \
--no-tablespaces \
| sed -E 's/DEFINER=`[^`]+`@`[^`]+`//g' \
| gzip -9 > "$OUT.gz"
ls -lh "$OUT.gz"
sha256sum "$OUT.gz" > "$OUT.gz.sha256"
Source: WP-CLI Commands: wp db export
wp db export runs mysqldump with the credentials of wp-config.php, so it works where the database user has no
command-line password to type, and - writes to standard output. Extra arguments are passed to mysqldump. Without
WP-CLI the equivalent is mysqldump --single-transaction --default-character-set=utf8mb4 --no-tablespaces --user ... lumenpress.
--no-tablespaces avoids the PROCESS privilege error on managed databases. On a MySQL source add
--set-gtid-purged=OFF, or the dump contains SET @@GLOBAL.GTID_PURGED statements that RDS does not let you run.
#!/usr/bin/env bash
set -euo pipefail
RDS_HOST="${RDS_HOST:?set to the RDS endpoint of lumenpress-prod}"
DUMP="${1:?usage: import.sh dump.sql.gz}"
curl --fail --silent --show-error --output global-bundle.pem \
https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem
read -r -s -p "Password of wp_migrate: " MYSQL_PWD
export MYSQL_PWD
echo
sha256sum --check "$DUMP.sha256"
gunzip --stdout "$DUMP" | mariadb \
--host "$RDS_HOST" \
--user wp_migrate \
--ssl-ca=global-bundle.pem \
--ssl-verify-server-cert \
lumenpress
RDS for MariaDB 11.8 enforces TLS by default (require_secure_transport is on since 11.8.3), so a client without the CA
bundle fails with a handshake error, which is the right default. The MySQL client takes --ssl-mode=VERIFY_IDENTITY
instead of --ssl-verify-server-cert. Rehearse the import to learn its duration: it is the part of the downtime that you
cannot shorten except with DMS.
AWS DMS for a low-downtime cutover
When the dump takes longer than the maintenance window allows, AWS Database Migration Service copies the existing rows (the full load) and then replays the changes read from the old server’s binary log (change data capture, CDC) until you stop the task. The old site keeps serving during the copy, and the downtime shrinks to the time needed to freeze writes, let the replication lag reach zero and switch.
The conditions on the old server are a real cost: binary logging enabled with binlog_format=ROW and
binlog_row_image=FULL, a retention long enough to cover the full load, a user with REPLICATION CLIENT and
REPLICATION SLAVE privileges plus SELECT on the schema, and network access from the replication instance in your VPC (a
VPN, Direct Connect or a public address restricted by security group). A single VPS with a MySQL on localhost and no
binary log rarely satisfies them without a restart, which is itself a small outage. Two properties of DMS matter for
WordPress:
-
DMS creates tables and primary keys on the target but does not copy secondary indexes, foreign keys, triggers or views the way a dump does. The usual practice is to create the schema first from
mysqldump --no-dataand set the task not to touch it (TargetTablePrepModeofDO_NOTHING). WordPress tables all have primary keys, which CDC needs. -
DMS moves bytes; it does not know about serialized PHP. If the site address changes,
wp search-replacehas to run on the target after the CDC has stopped, because a running task overwrites the changed rows. The simplest plan therefore keeps the same address on the old and new site (www.lumenpress.examplestays) and changes only DNS.
{
"rules": [
{
"rule-type": "selection",
"rule-id": "1",
"rule-name": "include-wordpress-tables",
"object-locator": {
"schema-name": "lumenpress",
"table-name": "%"
},
"rule-action": "include"
}
]
}
{
"TargetMetadata": {
"TargetSchema": "",
"SupportLobs": true,
"FullLobMode": false,
"LobChunkSize": 64,
"LimitedSizeLobMode": true,
"LobMaxSize": 32768
},
"FullLoadSettings": {
"TargetTablePrepMode": "DO_NOTHING",
"MaxFullLoadSubTasks": 8
},
"Logging": {
"EnableLogging": true
}
}
#!/usr/bin/env bash
set -euo pipefail
aws dms create-replication-task \
--replication-task-identifier lumenpress-cdc \
--source-endpoint-arn "$SOURCE_ENDPOINT_ARN" \
--target-endpoint-arn "$TARGET_ENDPOINT_ARN" \
--replication-instance-arn "$REPLICATION_INSTANCE_ARN" \
--migration-type full-load-and-cdc \
--table-mappings file://table-mappings.json \
--replication-task-settings file://task-settings.json
aws dms start-replication-task \
--replication-task-arn "$TASK_ARN" \
--start-replication-task-type start-replication
LOB settings matter because wp_posts.post_content and wp_postmeta.meta_value are LONGTEXT columns and
LimitedSizeLobMode truncates values above LobMaxSize (in KB); set it above the largest value of your data (find it with
SELECT MAX(LENGTH(post_content))) or use full LOB mode and accept the slower load. Verify with the task’s validation
setting or by comparing row counts and checksums of the largest tables before the cutover. DMS and its limits for
MariaDB and MySQL sources are documented per engine version; read the page for your source version.
Moving the media
Uploads are a pile of files that only grows, and the first copy takes hours while the delta at the cutover takes seconds. Plan two or three passes: a first copy days ahead, repeated copies as they change, and the last one inside the cutover.
To S3 with an offload plugin
The object store is the target of the production design (see
Media: S3 Offload, EFS and CloudFront). The files go with
aws s3 sync; the database must also learn that they are there, because an offload plugin stores per-attachment
metadata (the bucket, the key, whether the local copy was removed) and rewrites URLs on the fly based on it.
#!/usr/bin/env bash
set -euo pipefail
SRC="${SRC:-/var/www/html/wp-content/uploads}"
BUCKET="s3://lumenpress-prod-media/wp-content/uploads"
# Always look first.
aws s3 sync "$SRC" "$BUCKET" --dryrun --exclude "*.php" | tail -n 5
aws s3 sync "$SRC" "$BUCKET" \
--exclude "*.php" \
--exclude "*/.htaccess" \
--exclude "cache/*" \
--exclude "backup*" \
--size-only \
--only-show-errors
--exclude "*.php" keeps stray PHP files, a common sign of a compromised uploads directory, out of the new bucket.
--size-only makes repeated passes fast; use --exact-timestamps instead if files are replaced with the same size. The
key prefix has to match the one the offload plugin expects, which depends on the plugin and its settings; WP Offload Media
and Media Cloud are examples of plugins with their own bulk tools and WP-CLI commands for registering existing
attachments, and their documentation, not this page, is the authority for the exact command. After the sync, run the
plugin’s bulk "upload existing media" step in staging against a copy of the database to confirm that the rows and the keys
agree before production.
To EFS
When uploads stay on a shared file system, a one-off Job mounts the PersistentVolumeClaim and copies from a staging
location. The Job below pulls from an S3 staging prefix, so the copy from the old server is again aws s3 sync and no
long-lived network path from the cluster to the old machine exists.
apiVersion: batch/v1
kind: Job
metadata:
name: lumenpress-uploads-import
namespace: lumenpress
spec:
backoffLimit: 0
ttlSecondsAfterFinished: 86400
template:
spec:
restartPolicy: Never
serviceAccountName: lumenpress-uploads-import
securityContext:
runAsUser: 33
runAsGroup: 33
fsGroup: 33
containers:
- name: sync
image: amazon/aws-cli:<pinned-version>
command: ["aws"]
args:
- s3
- sync
- s3://lumenpress-prod-media/staging-import/
- /uploads/
- --only-show-errors
volumeMounts:
- name: uploads
mountPath: /uploads
volumes:
- name: uploads
persistentVolumeClaim:
claimName: lumenpress-uploads
Source: Kubernetes Docs: Jobs
The Job runs as UID 33 (www-data), so the files are owned by the user that PHP-FPM runs as; the access point or the
fsGroup of the EFS volume has to agree, as described on the media page. The ServiceAccount needs s3:GetObject and
s3:ListBucket on the staging prefix through Pod Identity
(Amazon EKS Clusters).
Changing the site address with wp search-replace
WordPress stores absolute URLs in the database: siteurl and home, every guid, links in post_content, serialized
widget and theme options, and page-builder JSON in wp_postmeta. If the address stays https://www.lumenpress.example,
nothing has to change. If the old site ran on a staging name, an IP address or http://, rewrite on a copy of the
imported database, never directly on the old server’s live database.
#!/usr/bin/env bash
set -euo pipefail
OLD="http://203.0.113.10"
NEW="https://www.lumenpress.example"
run() { kubectl --namespace lumenpress exec deploy/lumenpress-web --container php-fpm -- wp "$@"; }
# 1. Report what would change, without writing.
run search-replace "$OLD" "$NEW" --all-tables-with-prefix --skip-columns=guid --dry-run --report-changed-only
# 2. Apply. WP-CLI unserializes PHP data, replaces inside it and serializes it again with the right lengths.
run search-replace "$OLD" "$NEW" --all-tables-with-prefix --skip-columns=guid --report-changed-only
# 3. JSON stored by block themes and page builders escapes slashes, which step 2 does not match.
run search-replace 'http:\/\/203.0.113.10' 'https:\/\/www.lumenpress.example' --all-tables-with-prefix --skip-columns=guid --report-changed-only
# 4. Rebuild caches that held the old address.
run cache flush
run rewrite flush
Never run a plain SQL UPDATE ... REPLACE() on serialized data: the length prefixes (s:22:"...") no longer match and
PHP discards the whole option. --skip-columns=guid follows the WordPress guidance that a guid is an identifier, not a
link, and should not change. On a multisite network use --network and one --url per site, and change wp_blogs and
wp_site domains explicitly. wp runs here inside a web Pod, which is possible before the DNS switch because the Pods
already serve internally through the load balancer; alternatively run it from a Job of the same image.
Rebuilding the configuration
The old wp-config.php is a mixture of settings that belong in the environment, secrets that belong in Secrets Manager,
and local habits that belong nowhere. The migration is the moment to separate them. wp config list from the inventory
gives the constants; map each one:
| Old location | Setting | In the cluster |
|---|---|---|
|
|
|
|
|
The same Secret. Reusing the old salts keeps users logged in across the cutover; new salts log everyone out, which is a good reason to rotate after a suspected compromise |
|
|
Must stay identical to the imported tables |
|
|
Values of the chart, with HTTPS detection behind the load balancer (Scaling and High Availability) |
|
|
Off in production; logs go to stderr |
|
|
|
|
custom constants of plugins (API keys, licence keys) |
Secrets or values per plugin; remove licence keys from any file that reaches Git |
|
permalinks, redirects, headers, |
Nginx server block and PHP ini of the image |
|
|
PHP ini of the image and |
crontab |
|
CronJob |
Local MTA |
|
SES SMTP or API credentials from Secrets Manager |
|
cache drop-ins |
Removed; the Redis Object Cache plugin (which works with Valkey) or a compatible alternative is part of the image |
TLS certificates on the server |
Let’s Encrypt or purchased certificate |
ACM certificate on the load balancer (Route 53 and Certificates) |
Keep the old and the new configuration side by side until the cutover is over. Differences in PHP version are the other
quiet risk: the old server may still run PHP 7.4 or 8.1 with plugins that were never updated, and the target runs PHP 8.4.
Run the old site’s plugins under PHP 8.4 in staging, with WP_DEBUG_LOG on, and fix or replace what logs deprecations
that are about to become errors
(Coding Standards and Static Analysis).
Lightsail and Bitnami instances
Many small WordPress sites run on an Amazon Lightsail instance created from the "WordPress" blueprint, a Bitnami stack: WordPress, Apache, PHP and MariaDB on one machine. Broadcom, which owns Bitnami, announced that on 2026-06-10 the Bitnami containers (ECR), Helm charts, AMIs on AWS Marketplace and the Lightsail blueprints were removed from AWS. Existing instances keep running, but they receive no Bitnami updates after 2026-05-20, and no new instance can be created from those blueprints. A site on such an instance is therefore on an unmaintained stack: the operating system and the Bitnami packages (Apache, PHP, MariaDB) are not updated, and WordPress core and plugins are still updated by the WordPress updater only. That is the reason to migrate, and the migration is the one on this page with these specifics:
-
The layout is typically
/opt/bitnami/wordpressfor the code,/opt/bitnami/mariadbfor the database, Apache under/opt/bitnami/apacheand a WP-CLI under/opt/bitnami/wp-cli, with generated application credentials in the home directory of thebitnamiuser. Confirm all of it on your instance before you rely on it, then setWP_PATHin the scripts above accordingly. Bitnami builds often define the site address inwp-config.phpfrom the request ($_SERVER['HTTP_HOST']); replace it with fixedWP_HOMEandWP_SITEURLvalues. -
The database user and name are generated by the blueprint (names such as
bn_wordpressandbitnami_wordpressare typical; confirm them inwp-config.php); they are not carried over.wp_appandlumenpresson RDS replace them. -
The Bitnami stack puts several settings in its own Apache include files and
.htaccess-like fragments (HTTP to HTTPS redirects,X-Forwarded-Protohandling, XML-RPC andwp-loginrules); inventory them from the Apache configuration and re-create what you need in Nginx. -
Lightsail snapshots of the instance are the best backup before touching anything, and a static IP and a Lightsail DNS zone are common: the DNS records move to Route 53 or stay where they are, with the TTL lowered as in the cutover below.
-
Do not "rebuild" the stack with Bitnami’s replacement images or charts unless you have decided to pay for them: since August 2025 the maintained images are in a commercial catalogue, and the public ones in the legacy repository get no updates. The target of this page builds its image from the official
wordpressimage.
cPanel and shared hosting
On shared hosting the constraint is access, not size.
-
Shell access (SSH on many cPanel hosts, Terminal in cPanel) allows the scripts above unchanged. Without it, export the database with phpMyAdmin (it times out and limits uploads on large databases) or with a plugin that exports in chunks, and copy the files over SFTP or through the "Backup" or "Backup Wizard" screens, which produce an archive with the home directory and the MySQL dumps; the structure of the archive varies by version, so inspect it.
-
Database names and users carry the cPanel account as a prefix (
account_lumen), and the dump may includeCREATE DATABASEorUSElines for that name; remove them or import with the target name explicit. -
Add-on domains and sub-directories: a site in
public_html/bloghas its address insiteurl, so the move to the root of the new domain is awp search-replaceof the path as well as the host. -
PHP settings live in MultiPHP INI Editor or
.user.ini; carry the values you changed. -
Email often works through the host’s server and a mailbox on the same account; moving DNS without moving mail breaks both. Move only the website records and keep MX records and the mailboxes where they are, or migrate mail separately.
-
A host that wraps WordPress (its own cache plugin, its own
wp-content/mu-plugins) leaves traces: remove the host’s drop-ins and must-use plugins before the import.
When none of this is possible, the plugin-based tools (All-in-One WP Migration, Duplicator, UpdraftPlus and similar) are an ecosystem option; they produce an archive with database and files that you import into the new site’s staging, and the rest of this page (media to S3, configuration, cutover) still applies. Their behaviour is documented by each plugin.
Cutover with DNS TTLs
The cutover is a sequence with a decision in the middle, so write it down as a runbook with times, owners and the command for each step, and rehearse it on a copy.
DNS caches answers for the TTL of the record. If the old record has a TTL of 86400 seconds, lowering it on the day of the cutover achieves nothing for the resolvers that cached it yesterday: lower it at least one old TTL before the cutover (two days is the safe margin for a one-day TTL), keep it at 60 seconds through the cutover, and raise it again a day after. Alias records in Route 53 that point to the load balancer have no TTL of their own; Route 53 answers with a short value, so the speed of the switch after the cutover is set by the TTL of the old record that clients still hold.
#!/usr/bin/env bash
set -euo pipefail
# 0. T-48h: lower the TTL of www.lumenpress.example to 60 s (Route 53 or the current DNS host).
# 1. T-0: freeze writes on the old server.
wp --path="${WP_PATH:-/var/www/html}" maintenance-mode activate
crontab -r # after saving the crontab from the inventory
# 2. Final database copy: export.sh and import.sh, or stop the DMS task once its lag is zero.
./export.sh
./import.sh /tmp/lumenpress-final.sql.gz
# 3. Final media pass.
./sync-media.sh
# 4. Same-address site: no search-replace. Otherwise run the search-replace script.
# 5. Smoke test the new site before any client reaches it.
ALB_IP="$(dig +short lumenpress-alb.eu-west-1.elb.amazonaws.com | head -n 1)"
curl --fail --silent --show-error --resolve "www.lumenpress.example:443:${ALB_IP}" \
https://www.lumenpress.example/ --output /dev/null
# 6. Switch DNS (see the change batch below), then watch.
curl --resolve sends the request to the load balancer’s address with the right Host header and TLS name, so the new site
is verified through the real path (listener rule, certificate, Nginx, PHP, RDS) without changing DNS. Check a post, a page
with images, a search, a login and, if the site has forms, a submission to a test endpoint. The DNS switch itself is one
change batch:
{
"Comment": "Cutover of www.lumenpress.example to the EKS load balancer",
"Changes": [
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "www.lumenpress.example.",
"Type": "A",
"AliasTarget": {
"HostedZoneId": "<alb-hosted-zone-id>",
"DNSName": "dualstack.lumenpress-alb-1234567890.eu-west-1.elb.amazonaws.com.",
"EvaluateTargetHealth": true
}
}
}
]
}
#!/usr/bin/env bash
set -euo pipefail
ZONE_ID="<hosted-zone-id>"
CHANGE_ID="$(aws route53 change-resource-record-sets \
--hosted-zone-id "$ZONE_ID" \
--change-batch file://change-batch.json \
--query 'ChangeInfo.Id' --output text)"
aws route53 wait resource-record-sets-changed --id "$CHANGE_ID"
dig +short www.lumenpress.example @ns-example.awsdns-00.org
A switch with weighted records (ten percent of the clients, then fifty, then all) is tempting and is wrong for a site that writes: during the transition, comments, logins, orders and edits reach two databases that do not replicate to each other. Weighted routing is fine for a read-only site, or when DMS keeps both databases in step in one direction and the new site is the only writer. For everything else use one switch and a short TTL. The Lumen Press runbook, and the zone details, are also on Route 53 and Certificates.
Verifying that nothing was lost
Compare before declaring success. The counts must agree between the frozen old site and the new site, and a crawl must show no new errors.
#!/usr/bin/env bash
set -euo pipefail
OLD="${OLD:?base URL of the old site, e.g. https://old.lumenpress.example}"
NEW="${NEW:?base URL of the new site}"
curl --fail --silent --show-error "$OLD/wp-sitemap.xml" \
| grep -oE 'https?://[^<]+' | sort -u > sitemaps.txt
: > urls.txt
while read -r sitemap; do
curl --fail --silent --show-error "$sitemap" | grep -oE '<loc>[^<]+' | sed 's/<loc>//' >> urls.txt
done < sitemaps.txt
mismatches=0
while read -r url; do
path="${url#*://*/}"
old_code="$(curl --silent --output /dev/null --write-out '%{http_code}' "$OLD/$path")"
new_code="$(curl --silent --output /dev/null --write-out '%{http_code}' "$NEW/$path")"
if [ "$old_code" != "$new_code" ]; then
echo "MISMATCH $path old=$old_code new=$new_code"
mismatches=$((mismatches + 1))
fi
done < urls.txt
echo "checked $(wc -l < urls.txt) URLs, $mismatches mismatches"
[ "$mismatches" -eq 0 ]
Source: Code Reference: WP_Sitemaps
On both sites also compare wp post list --post_type=any --post_status=any --format=count, wp user list --format=count and
the row counts of the largest tables, and open a few old uploads that were attached to old posts (the media pass most often
misses sub-sizes and files outside uploads/YYYY/MM). Check that the REST API (/wp-json/), wp-login.php, the sitemap, a
scheduled post publishing and an outgoing email all work on the new site, because those are the paths the crawl does not
reach.
Rollback plan
Decide the rollback before the cutover, with the criteria that trigger it (for example: login failures above a threshold, checkout errors, a database error rate, or no recovery within thirty minutes).
| When | What happened | Rollback |
|---|---|---|
Before the DNS switch |
Smoke test failed |
Re-enable the old site ( |
Within minutes after the switch |
The new site is broken, few writes happened |
Switch the record back to the old server and re-enable it; some clients still reach the new site until their TTL expires (60 s). Re-apply any writes that reached the new site only if they matter (orders, comments). |
After hours |
Real content or orders exist only on the new site |
Rolling back loses data unless you reverse-copy: export the changed rows from RDS and import them into the old server, or run DMS in the opposite direction. Usually it is faster to fix forward. |
Keep the old server until the rollback window closes (a week for a small site) and take its last backup before shutting
it down; keep the old database dump and the first RDS snapshot after the cutover (see
Backups and Disaster Recovery). Afterwards remove the wp_migrate
user, rotate every credential that was visible on the old server, and, if the old server was compromised, rotate the salts
and review users and plugins before anything is exported (Security Hardening).
Common errors and pitfalls
-
Import fails with
ERROR 1227: Access denied; you need (at least one of) the SUPER privilege: the dump containsDEFINERclauses orSET GLOBALstatements; strip them as inexport.sh. -
Import fails with
ERROR 1067: Invalid default value for 'post_date': the oldsql_modeallowed zero dates and the target does not; either fix the data on the old server or setsql_modein the parameter group for the import and remove it afterwards. -
Question marks or
éin text: the dump and the import used different character sets; export with--default-character-set=utf8mb4and check that the tableCHARSETclauses areutf8mb4. -
Pages show the old address or mixed content: the
search-replacemissed the JSON-escaped variant or a plugin option that stores the URL outside the tables with the prefix; run it with--all-tables. -
Images load, but the media library shows broken thumbnails: the sub-sizes (
-300x200) were not copied, or the offload plugin’s metadata does not match the bucket keys. -
Everyone is logged out: the salts changed. That is intended if you rotated them and a surprise if you did not copy them.
-
Admin works, public pages 404: the permalink structure relies on rewrite rules that were in
.htaccess; Nginx needs thetry_filesrule andwp rewrite flushshould be run. -
Scheduled posts do not publish:
DISABLE_WP_CRONis true and the CronJob does not run or cannot reach the database. -
DMS task stays in "Running" but the target lags: a long transaction on the source or LOBs in limited mode; inspect the task’s CDC latency metrics before the cutover.