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:

  1. Inventory the old server: what actually runs, not what the documentation says.

  2. Build the target from code: the chart, the image, the empty RDS instance, the bucket. Nothing here touches the old site.

  3. Rehearse with a copy: export the database, import it, copy the media, run wp search-replace if the address changes, boot the new Pods and compare. Repeat until it is boring, and time each step.

  4. Cut over: freeze writes on the old server, move the last delta, switch DNS, verify.

  5. Keep the old server for a defined period as the rollback.

flowchart TD A[Inventory the old server] --> B[Build target: image, chart, RDS, bucket] B --> C[Rehearsal: dump, import, media, search-replace] C --> D{Rehearsal matches the old site?} D -- no --> C D -- yes --> E[T-48h: lower DNS TTL to 60 s] E --> F[Cutover window: old site read-only] F --> G[Final dump or DMS stop, final media sync] G --> H[Smoke test the new site with curl --resolve] H --> I{Go?} I -- no --> R[Rollback: old site writable again, DNS untouched] I -- yes --> J[Switch DNS to the load balancer] J --> K[Watch errors, logins, orders for one hour] K --> L{Healthy?} L -- yes --> M[Keep old server read-only for 7 days, then retire] L -- no --> N[Rollback: DNS back, reverse-copy changes made on the new site]

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.

inventory.sh
#!/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

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_mode and the collation: WordPress uses utf8mb4 and, for MariaDB 11.8, the default collation of the server differs from older ones, so the dump carries explicit CHARSET and COLLATE clauses.

  • 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-content with archives), security plugins that add auto_prepend_file rules 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.php over HTTP or wp cron event run becomes the CronJob lumenpress-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.

  • .htaccess and PHP settings. Rewrites, redirects, headers, upload_max_filesize and memory_limit set 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_blogs and wp_site domains that wp search-replace --network must 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.

export.sh (on the old server)
#!/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"

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.

import.sh (from a host inside the VPC)
#!/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-data and set the task not to touch it (TargetTablePrepMode of DO_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-replace has 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.example stays) and changes only DNS.

table-mappings.json
{
  "rules": [
    {
      "rule-type": "selection",
      "rule-id": "1",
      "rule-name": "include-wordpress-tables",
      "object-locator": {
        "schema-name": "lumenpress",
        "table-name": "%"
      },
      "rule-action": "include"
    }
  ]
}
task-settings.json
{
  "TargetMetadata": {
    "TargetSchema": "",
    "SupportLobs": true,
    "FullLobMode": false,
    "LobChunkSize": 64,
    "LimitedSizeLobMode": true,
    "LobMaxSize": 32768
  },
  "FullLoadSettings": {
    "TargetTablePrepMode": "DO_NOTHING",
    "MaxFullLoadSubTasks": 8
  },
  "Logging": {
    "EnableLogging": true
  }
}
Creating the task (the endpoints and the replication instance exist already)
#!/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.

sync-media.sh
#!/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.

efs-import-job.yaml
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

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.

Searching and replacing in the imported 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

wp-config.php

DB_HOST, DB_NAME, DB_USER, DB_PASSWORD

DB_HOST and the name from Helm values; the password from the Secret lumenpress/prod/wordpress through the external-secrets resource of the chart; the user is wp_app, not the old account

wp-config.php

AUTH_KEY …​ NONCE_SALT

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

wp-config.php

$table_prefix

Must stay identical to the imported tables

wp-config.php

WP_HOME, WP_SITEURL, FORCE_SSL_ADMIN

Values of the chart, with HTTPS detection behind the load balancer (Scaling and High Availability)

wp-config.php

WP_DEBUG and logging

Off in production; logs go to stderr

wp-config.php

DISABLE_WP_CRON

true; the CronJob takes over

wp-config.php

custom constants of plugins (API keys, licence keys)

Secrets or values per plugin; remove licence keys from any file that reaches Git

.htaccess

permalinks, redirects, headers, php_value lines

Nginx server block and PHP ini of the image

php.ini, .user.ini, cPanel MultiPHP

memory_limit, upload_max_filesize, max_execution_time

PHP ini of the image and client_max_body_size of Nginx

crontab

wp-cron.php calls, scripts

CronJob lumenpress-cron, or separate CronJobs

Local MTA

mail()

SES SMTP or API credentials from Secrets Manager

wp-content/object-cache.php, advanced-cache.php

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/wordpress for the code, /opt/bitnami/mariadb for the database, Apache under /opt/bitnami/apache and a WP-CLI under /opt/bitnami/wp-cli, with generated application credentials in the home directory of the bitnami user. Confirm all of it on your instance before you rely on it, then set WP_PATH in the scripts above accordingly. Bitnami builds often define the site address in wp-config.php from the request ($_SERVER['HTTP_HOST']); replace it with fixed WP_HOME and WP_SITEURL values.

  • The database user and name are generated by the blueprint (names such as bn_wordpress and bitnami_wordpress are typical; confirm them in wp-config.php); they are not carried over. wp_app and lumenpress on 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-Proto handling, XML-RPC and wp-login rules); 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 wordpress image.

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 include CREATE DATABASE or USE lines for that name; remove them or import with the target name explicit.

  • Add-on domains and sub-directories: a site in public_html/blog has its address in siteurl, so the move to the root of the new domain is a wp search-replace of 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.

Runbook skeleton (comments are the steps; commands are the ones above)
#!/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:

change-batch.json
{
  "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
        }
      }
    }
  ]
}
Applying and watching the change
#!/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.

compare-sites.sh
#!/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 ]

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 (wp maintenance-mode deactivate, restore the crontab). Nothing was written elsewhere.

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 contains DEFINER clauses or SET GLOBAL statements; strip them as in export.sh.

  • Import fails with ERROR 1067: Invalid default value for 'post_date': the old sql_mode allowed zero dates and the target does not; either fix the data on the old server or set sql_mode in 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=utf8mb4 and check that the table CHARSET clauses are utf8mb4.

  • Pages show the old address or mixed content: the search-replace missed 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 the try_files rule and wp rewrite flush should be run.

  • Scheduled posts do not publish: DISABLE_WP_CRON is 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.