Cloud: Migrating from a Single Server

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against Magento Open Source and Adobe Commerce 2.4.9 and Mage-OS 3.6.0 (PHP 8.5), RDS for MySQL 8.4 as the target database, Amazon OpenSearch Service 3.x, Amazon S3 for the media, and Amazon EKS with Kubernetes 1.36 or 1.37. It was checked on 2026-10-29. The Magento behaviour described here was read in the source of the 2.4-develop branch of magento/magento2 (Magento\Framework\Encryption\Encryptor, Magento\Framework\App\DeploymentConfig, Magento\RemoteStorage\Model\Synchronizer, the system.xml files of the Backend, Store and OpenSearch modules and QueueManagement) and in Adobe’s documentation. The shell scripts were checked with shellcheck, the PHP with php -l, and the SQL and the DEFINER filter were run against a scratch MariaDB 11.8 server that contained a small core_config_data table and a trigger. Nothing was run against a real AWS account, a real payment provider or a real Magento installation of Pocket Coffee, so every step must be rehearsed on a copy, and the sections below say what the documentation states and what you must test.

The shop is Pocket Coffee, the fictitious specialty-coffee seller of the other Magento pages. Its single server runs Nginx, PHP-FPM, MariaDB, OpenSearch and Valkey for https://www.pocketcoffee.example (three store views, es, en and us, and the modules PocketCoffee_RoastAdmin, PocketCoffee_Pay, PocketCoffee_Skeleton and PocketCoffee_Api). It moves to the cluster pocketcoffee-prod (namespace pocketcoffee, release pocketcoffee) in eu-west-1, with the database in the RDS instance pocketcoffee-prod (MySQL 8.4, database pocketcoffee, users mg_app and mg_migrate, empty table prefix), the media in the S3 bucket pocketcoffee-prod-media behind CloudFront, the search index in the OpenSearch domain pocketcoffee-prod and every secret in the Secrets Manager secret pocketcoffee/prod/magento. The target is described on Cloud: Reference Architecture, the database on Cloud: Managed Database (RDS and Aurora), the search, cache and queue services on Cloud: OpenSearch, Valkey and RabbitMQ as Managed Services, the image on Cloud: Container Image and Pipeline Build, the media on Cloud: Media and Remote Storage, the indexers and consumers on Cloud: Cron, Queue Consumers and Indexers and the Helm chart on Cloud: EKS Infrastructure and Helm Deployment. The old environment is the one described on Local Development Environments and Installation. The same move for the other shops is on PrestaShop: Migrating from a Single Server, on WooCommerce: Migrating a Store and, for WordPress, on Migrating from a Single Server; the AWS Database Migration Service and the DNS mechanics are explained once on Architecture, Migration and Hybrid.

The shape of a migration

A Magento shop on one server keeps five kinds of state: the database, the media (pub/media), the search index (in OpenSearch), the code and configuration (the Composer project, app/etc/config.php, app/etc/env.php) and a lot of transient state (sessions, cache, generated code, queue messages) that is not worth moving. The cluster stores each in a different place, so a migration is several moves that must agree with each other at the end.

State on the old server Where it goes How it moves

Database (all tables, including core_config_data)

RDS pocketcoffee-prod

mysqldump and import, or AWS DMS for a short freeze

pub/media

S3 bucket pocketcoffee-prod-media (or EFS), behind CloudFront

aws s3 sync with excludes, or bin/magento remote-storage:sync

Search index

OpenSearch domain pocketcoffee-prod

Not copied: rebuilt with indexer:reindex after the import

Code, app/etc/config.php, modules, themes

The container image

Not copied: rebuilt from your repository; the differences on the old server are found in the inventory and committed first

app/etc/env.php

A file rendered from environment variables and the Secrets Manager secret pocketcoffee/prod/magento

Rebuilt, keeping crypt/key identical, never generated again

Base URLs and media URLs

Rows of core_config_data

Changed in the database after the import, see Base URLs

Sessions, cache, var/, generated/, pub/static

Valkey, per-pod emptyDir, the image

Not copied: customers log in again and the cache starts cold

The rule that keeps a shop safe is the one of every migration with orders: at any moment exactly one side takes orders. The old server stays untouched until the new shop has answered real requests, and it stays available as the rollback.

Cutover timeline of the Pocket Coffee Magento shop, three lanes: old server, EKS stack, and DNS with the payment provider

Inventory of the old server

Do not start the new stack before you know what the old one contains. The inventory finds the facts that decide the method (the database size and engine, the queue backend, the indexer modes) and the differences between the production server and the repository the image is built from, which are the usual cause of "it worked on the old server". It reads the configuration by key path, never by value, so its output can be pasted into a ticket.

<?php
// env-key-paths.php - list the key paths of app/etc/env.php and a few harmless values. Never prints a secret.
declare(strict_types=1);

$file = $argv[1] ?? 'app/etc/env.php';
$env = require $file;

/** @param array<string, mixed> $node */
function walk(array $node, string $path, int $depth): void
{
    foreach ($node as $key => $value) {
        $here = $path === '' ? (string)$key : $path . '/' . $key;
        if (is_array($value) && $depth < 3 && $here !== 'system') {
            walk($value, $here, $depth + 1);
        } else {
            echo $here, "\n";
        }
    }
}

function get(array $env, string $path): string
{
    $node = $env;
    foreach (explode('/', $path) as $part) {
        if (!is_array($node) || !array_key_exists($part, $node)) {
            return '(not set)';
        }
        $node = $node[$part];
    }
    return is_scalar($node) ? (string)$node : '(array)';
}

echo "== key paths\n";
walk($env, '', 0);

echo "== harmless values\n";
foreach (['MAGE_MODE', 'backend/frontName', 'db/table_prefix', 'session/save', 'cache/frontend/default/backend',
          'cache/frontend/page_cache/backend', 'queue/default_connection', 'remote_storage/driver',
          'lock/provider', 'x-frame-options'] as $path) {
    echo $path, ' = ', get($env, $path), "\n";
}

// the key itself is never printed: only how many keys the value holds (more than one after a key change)
$keys = preg_split('/\s+/', trim((string)($env['crypt']['key'] ?? '')), -1, PREG_SPLIT_NO_EMPTY);
echo 'crypt/key entries: ', is_array($keys) ? count($keys) : 0, "\n";
echo 'install/date present: ', isset($env['install']['date']) ? 'yes' : 'no', "\n";
echo 'db connections: ', implode(', ', array_keys($env['db']['connection'] ?? [])), "\n";
#!/usr/bin/env bash
# inventory.sh - read-only facts about a Magento shop. Run on the old server as the owner of the files.
set -euo pipefail

MAGENTO_DIR="${MAGENTO_DIR:-/var/www/html}"
HERE="$(cd "$(dirname "$0")" && pwd)"
cd "$MAGENTO_DIR"

echo "== Magento, PHP and modules"
php bin/magento --version
php -v | head -n 1
php bin/magento module:status | head -n 3
php bin/magento module:status --disabled | wc -l

echo "== env.php (paths only)"
php "$HERE/env-key-paths.php" app/etc/env.php

echo "== Database"
# shellcheck disable=SC2016  # the PHP code is single-quoted on purpose
DB_NAME="$(php -r '$e = require "app/etc/env.php"; echo $e["db"]["connection"]["default"]["dbname"];')"
MYSQL=(mysql --defaults-extra-file="$HOME/.my.cnf" --batch)
"${MYSQL[@]}" -e "SELECT VERSION();"
"${MYSQL[@]}" -e "
  SELECT table_name, ROUND((data_length + index_length) / 1024 / 1024) AS mb, engine
    FROM information_schema.tables
   WHERE table_schema = '${DB_NAME}'
   ORDER BY (data_length + index_length) DESC LIMIT 15;"
echo "-- tables that are not InnoDB"
"${MYSQL[@]}" -e "
  SELECT table_name, engine FROM information_schema.tables
   WHERE table_schema = '${DB_NAME}' AND engine <> 'InnoDB' AND table_type = 'BASE TABLE';"
echo "-- objects with a DEFINER (triggers, routines, views)"
"${MYSQL[@]}" -e "
  SELECT 'trigger' AS kind, trigger_name AS name, definer FROM information_schema.triggers WHERE trigger_schema = '${DB_NAME}'
  UNION ALL SELECT 'routine', routine_name, definer FROM information_schema.routines WHERE routine_schema = '${DB_NAME}'
  UNION ALL SELECT 'view', table_name, definer FROM information_schema.views WHERE table_schema = '${DB_NAME}';" | head -n 40
echo "-- queue backend: pending messages in the database queue"
"${MYSQL[@]}" -e "
  SELECT status, COUNT(*) AS messages FROM queue_message_status GROUP BY status;"
echo "-- store views, websites and the URL rows (paths and values, no secrets)"
"${MYSQL[@]}" -e "SELECT store_id, code, website_id, is_active FROM store ORDER BY store_id;"
"${MYSQL[@]}" -e "
  SELECT scope, scope_id, path, value FROM core_config_data
   WHERE path REGEXP '^web/(un)?secure/base_(url|link_url|static_url|media_url)$' ORDER BY path, scope, scope_id;"

echo "== Indexers and cache"
php bin/magento indexer:status
php bin/magento cache:status | head -n 20

echo "== Media"
du -sh pub/media pub/media/* 2>/dev/null | sort -h | tail -n 12
find pub/media -type f | wc -l

echo "== Code changed on the server (not in the repository)"
find app/code -mindepth 2 -maxdepth 2 -type d 2>/dev/null
find app/design -mindepth 2 -maxdepth 2 -type d 2>/dev/null
test -f composer.lock && sha256sum composer.lock
test -d patches && ls patches

echo "== Scheduled tasks"
crontab -l 2>/dev/null || true

What to do with each result:

  • The database size decides between a dump and DMS (see AWS DMS). A catalogue of tens of thousands of products with a few years of orders is typically some gigabytes, which a dump moves in minutes to an hour. Measure it in the rehearsal, never estimate it.

  • The biggest tables on a Magento shop are rarely the catalogue. Look for cron_schedule, customer_visitor, quote and its item tables (abandoned carts), report_event and report_viewed_product_index, url_rewrite, queue_message and the grid and index tables. Whether a table may be emptied before the move depends on what your shop does with it; clean them on the old server a week before, not during the freeze, and keep what the business needs.

  • Non-InnoDB tables. Core Magento tables are InnoDB; old shops and some extensions have MyISAM tables. They import into RDS, but they are not covered by --single-transaction and are locked while they are dumped. Convert them with ALTER TABLE ... ENGINE=InnoDB on a copy during the rehearsal.

  • DEFINER. The list of triggers, routines and views with their definer is the input of the DEFINER section. A shop whose indexers run in "Update by Schedule" has the materialised-view triggers that Magento creates, one set per subscribed table, owned by the database user that created them.

  • The queue backend. queue/default_connection of env.php is db or amqp. With db the pending messages are in queue_message and queue_message_status and travel with the dump (see the freeze step); with amqp they are in the RabbitMQ of the old server, which the cluster does not read. The status values are in Magento\MysqlMq\Model\QueueManagement: 2 new, 3 in progress, 4 complete, 5 retry required, 6 error and 7 to be deleted.

  • Several store views and URL rows. More than one scope row for a base URL means a multi-website or multi-host setup, as on Catalog, Multistore and B2B; the URL change below must handle every row.

  • Code that exists only on the server. A module under app/code, a patched vendor file, a theme edited in place or a patches/ folder exists only on the old server. Copy them into the repository and rebuild the image; the cluster has no way to keep a hand edit on a pod, and the old server stops being the source of the code. The composer.lock hash tells you whether the repository and the server agree.

  • Disabled modules and config.php. app/etc/config.php lists every module and whether it is enabled, and the image carries the committed copy (see Cloud: Container Image and Pipeline Build). Compare module:status of the old server with the committed config.php before the first rehearsal.

  • Scheduled tasks. The crontab of the file-system owner holds the block that cron:install wrote between #~ MAGENTO START and #~ MAGENTO END, plus anything of your own (a feed export, a backup). The Magento block is replaced by the CronJob pocketcoffee-cron; anything else becomes another CronJob. Remove the old crontab at the freeze, or both sides run the same jobs.

Preparing the target

Do not run setup:install against the production database. The installer creates a fresh schema and a new crypt/key, which is the opposite of what a migration needs; the Job pocketcoffee-install of the chart is for a shop that starts empty. The target is prepared in this order:

  1. The RDS instance, its parameter group (utf8mb4, log_bin_trust_function_creators, a max_allowed_packet at least as large as the largest row of the dump) and the two users mg_app and mg_migrate exist, as described on Cloud: Managed Database (RDS and Aurora). The database pocketcoffee is empty.

  2. The Secrets Manager secret pocketcoffee/prod/magento holds the database passwords and the crypt_key of the old shop (see Rebuilding env.php). The ExternalSecret pocketcoffee-magento has produced the Kubernetes Secret of the same name.

  3. The OpenSearch domain, the Valkey replication groups and the Amazon MQ broker exist and are reachable from the cluster (Cloud: OpenSearch, Valkey and RabbitMQ as Managed Services).

  4. The image 123456789012.dkr.ecr.eu-west-1.amazonaws.com/pocketcoffee/magento was built from your repository with the same Magento version the old shop runs. Migrate first and upgrade afterwards: a move that is also a version upgrade has two causes for every failure. The upgrade itself is on Cloud: Releases and Zero-Downtime Deployments and Upgrades.

  5. The S3 bucket pocketcoffee-prod-media and the migration bucket pocketcoffee-prod-migration (a temporary bucket for the dump, see Import into RDS) exist; the bucket is private and the CloudFront distribution reads it with an origin access control.

  6. The Deployment pocketcoffee-web can be scaled to zero (web.replicas: 0 in the values) and the CronJob pocketcoffee-cron can be suspended until the data is in place, so no pod and no scheduler starts against an empty or half-imported database.

The rehearsal repeats everything on this page against a copy: a dump of the old shop, a scratch database and a scratch bucket or prefix. It is the only way to learn how long the dump, the import, the media delta and the reindex take, which decides the length of the freeze.

Moving the database

Dump

Take the dump from a consistent snapshot without locking the shop. For InnoDB tables --single-transaction does that; the shop keeps selling while the dump runs, which is why the final dump of the cutover is taken only after the freeze.

#!/usr/bin/env bash
# dump.sh - consistent dump of the Magento database from the old server, with DEFINER clauses removed.
set -euo pipefail

: "${DB_NAME:?set DB_NAME}"
OUT="${OUT:-pocketcoffee-$(date +%Y%m%d-%H%M).sql.gz}"
# MySQL source only: RDS refuses the GTID_PURGED statement. MariaDB's dump tool does not know the option.
EXTRA_OPTS=()
if [ "${SOURCE_ENGINE:-mariadb}" = "mysql" ]; then
  EXTRA_OPTS+=(--set-gtid-purged=OFF)
fi

# --single-transaction     consistent InnoDB snapshot, no table locks
# --routines --events      stored routines and events (extensions may add them)
# --triggers               is the default; written so the intent is visible: Magento creates triggers for the indexers
# --default-character-set  the tables are utf8mb4; the client default can differ on an old server
# --no-tablespaces         the dump user does not need the PROCESS privilege
# --hex-blob               binary columns survive any client character set
# the sed filter deletes DEFINER=`user`@`host` (and the unquoted form) from every line except data rows,
# which start with INSERT INTO (MySQL) or with an opening parenthesis (the rows MariaDB's dump tool puts on their own lines)
# shellcheck disable=SC2016  # the sed expression is single-quoted on purpose
mysqldump \
  --defaults-extra-file="$HOME/.my.cnf" \
  --single-transaction \
  --routines --events --triggers \
  --default-character-set=utf8mb4 \
  --no-tablespaces \
  --hex-blob \
  --max-allowed-packet=256M \
  "${EXTRA_OPTS[@]}" \
  "$DB_NAME" \
  | sed -E '/^(INSERT INTO|\()/!s/DEFINER[ ]*=[ ]*(`[^`]+`|[A-Za-z0-9_$%.-]+)@(`[^`]+`|[A-Za-z0-9_$%.-]+)//g' \
  | gzip -9 > "$OUT"

# a dump that ends without this marker is truncated
zcat "$OUT" | tail -n 1 | grep -q 'Dump completed' || { echo "truncated dump: $OUT" >&2; exit 1; }
echo "DEFINER clauses left in the dump: $(zcat "$OUT" | grep -c 'DEFINER=' || true)"
sha256sum "$OUT" > "$OUT.sha256"
echo "$OUT"

The options matter in these ways on Magento:

  • Character set. Magento creates its tables with utf8mb4 (older installations may have utf8, three bytes); dumping and importing with --default-character-set=utf8mb4 is what keeps accents and the euro sign of the es store view intact. A shop dumped with latin1 by a client default imports "successfully" with corrupted text.

  • The size of one row. Product, CMS and url_rewrite rows can be large; set max_allowed_packet on both the dump command and the RDS parameter group.

  • A MariaDB source and a MySQL 8.4 target. Magento 2.4.9 supports both engines (see Versions, Lifecycle and Requirements), but a MariaDB dump can contain syntax that MySQL does not accept. The Magento schema is plain; the usual cause is an extension table. The rehearsal import into a scratch RDS instance finds it, and moving MariaDB to RDS for MariaDB avoids the question. A MySQL 8.0 client’s mysqldump against a MariaDB or 5.7 server needs --column-statistics=0.

  • The tool name. On a MariaDB server mysqldump can be a link to mariadb-dump; the options above are accepted by both, except --set-gtid-purged, which the script sets only for a MySQL source.

DEFINER and log_bin_trust_function_creators

Two RDS rules decide whether triggers, routines and views survive the import, and Magento has triggers: in "Update by Schedule" mode every indexer subscribes to its source tables with database triggers (the shop of this page uses that mode).

  • DEFINER. A trigger, view, routine or event is created with a DEFINER (a user and a host). The dump writes the definer of the old server, for example magento@localhost . RDS users do not have the SUPER privilege, so creating an object whose definer is another or non-existent user fails (MySQL error 1227, "Access denied; you need (at least one of) the SUPER or SET_USER_ID privilege(s)"). mysqldump has no option to leave the definer out (the 8.4 option table lists none), so the clause is removed with sed, as the script above does between mysqldump and gzip. Without a definer, MySQL uses the importing user, mg_migrate. A trigger or view then belongs to mg_migrate, which is why that account must keep existing: dropping it later invalidates the objects. The alternative is to import with a definer that exists on RDS and keep it; the clean choice for a shop is one owner of the schema objects, the migration user.

  • Binary logging. RDS keeps binary logging on when automated backups are on. With binary logging on, MariaDB refuses to create a stored function or a trigger for a user without SUPER unless log_bin_trust_function_creators is 1; MySQL 8.4 created the trigger without it in the test on the managed database page, and the parameter group sets it anyway. The parameter lives in the RDS parameter group (pocketcoffee-magento-mysql84, see the managed database page), not in the session, and it is a dynamic parameter. Magento’s triggers fall under this rule; the symptom of a missing parameter on MariaDB is the error "You do not have the SUPER privilege and binary logging is enabled (you might want to use the less safe log_bin_trust_function_creators variable)" at the first CREATE TRIGGER of the import. Leave the parameter as the managed database page sets it; the importing user needs TRIGGER as well as the DDL privileges.

  • sed and the data rows. The filter skips the lines of data: those that begin with INSERT INTO (MySQL writes all the rows of a table on one such line) and those that begin with an opening parenthesis (the dump tool of MariaDB 11.8 writes each row on its own line, which the scratch test showed). A product description or CMS block that happens to contain the text DEFINER= is therefore not touched. A trigger, a view or a routine is one line of the dump, so the substitution reaches the whole statement. The words SQL SECURITY DEFINER of a view stay: they are valid without a definer clause and refer to the importing user.

  • Views carry the database name. The dump writes the definition of a view with the schema name inside ( olddb.table ). If the database of the target has another name than the old one, the import of the view stops with "Unknown column" or "Unknown table". Either keep the database name, or replace the old name in the dump in the same sed pass (the same exclusion of data rows), as in the example below the list. The inventory lists the views; Magento Open Source creates few of its own, but extensions and, on shops that use Multi-Source Inventory, the default stock view inventory_stock_1 are views (check yours).

Replacing the schema name of the old database (here magento) in a dump that goes into the database pocketcoffee, in the same way as the DEFINER filter, excluding data rows:

# shellcheck disable=SC2016  # the sed expression is single-quoted on purpose
zcat pocketcoffee-final.sql.gz \
  | sed -E '/^(INSERT INTO|\()/!s/`magento`\./`pocketcoffee`./g' \
  | gzip -9 > pocketcoffee-final-renamed.sql.gz

The filter was checked on a scratch MariaDB 11.8: a dump with a trigger, a view and a row of text that contained the string DEFINER= had three occurrences before the filter and none in the DDL after it, the data row was left intact, the import of the unfiltered dump as an unprivileged user stopped with error 1227 and the import of the filtered one created the trigger under the importing user (the view needed the schema-name replacement of the bullet on views). The behaviour on RDS for MySQL 8.4 is documented, not run, here.

Import into RDS

The cluster reaches RDS through private subnets and a security group that only allows the node group, so a laptop cannot import directly. Two patterns work: a Job in the cluster (nothing to maintain) or a bastion host in the VPC. Both use TLS with the RDS CA bundle. The Job below downloads the dump from the migration bucket in an init container, imports it with the migration user, and then runs the URL statements of the next section; the SQL file arrives in the ConfigMap pocketcoffee-cutover-sql. The password of mg_migrate comes from the Kubernetes Secret pocketcoffee-magento (key MAGENTO_MIGRATE_DB_PASSWORD, created from Secrets Manager), never from the manifest.

apiVersion: batch/v1
kind: Job
metadata:
  name: pocketcoffee-import
  namespace: pocketcoffee
spec:
  backoffLimit: 0
  ttlSecondsAfterFinished: 86400
  template:
    spec:
      restartPolicy: Never
      serviceAccountName: pocketcoffee-migrate   # Pod Identity role with s3:GetObject on pocketcoffee-prod-migration only
      volumes:
        - name: work
          emptyDir: {}
        - name: sql
          configMap:
            name: pocketcoffee-cutover-sql
      initContainers:
        - name: fetch
          image: amazon/aws-cli:2.34.0
          args:
            - s3
            - cp
            - s3://pocketcoffee-prod-migration/pocketcoffee-final.sql.gz
            - /work/dump.sql.gz
          volumeMounts:
            - name: work
              mountPath: /work
      containers:
        - name: import
          image: mysql:8.4
          command: ["/bin/bash", "-ec"]
          args:
            - |
              curl -fsSL -o /work/rds-ca.pem https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem
              MYSQL=(mysql --host="${DB_HOST}" --user=mg_migrate --password="${DB_PASSWORD}"
                     --default-character-set=utf8mb4 --ssl-mode=VERIFY_IDENTITY --ssl-ca=/work/rds-ca.pem)
              zcat /work/dump.sql.gz | "${MYSQL[@]}" pocketcoffee
              { echo "SET @old_base='${OLD_BASE}'; SET @new_base='${NEW_BASE}'; SET @new_media='${NEW_MEDIA}';"
                cat /sql/urls.sql; } | "${MYSQL[@]}" pocketcoffee
              "${MYSQL[@]}" pocketcoffee -e "SELECT COUNT(*) AS orders FROM sales_order; SELECT COUNT(*) AS customers FROM customer_entity;"
          env:
            - name: DB_HOST
              value: pocketcoffee-prod.example1234.eu-west-1.rds.amazonaws.com
            - name: OLD_BASE
              value: https://www.pocketcoffee.example/
            - name: NEW_BASE
              value: https://www.pocketcoffee.example/
            - name: NEW_MEDIA
              value: https://media.pocketcoffee.example/
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: pocketcoffee-magento
                  key: MAGENTO_MIGRATE_DB_PASSWORD
          volumeMounts:
            - name: work
              mountPath: /work
            - name: sql
              mountPath: /sql

The host name is a placeholder: use the endpoint of your instance (tofu output or aws rds describe-db-instances). The importing user needs CREATE, ALTER, DROP, INDEX, INSERT, LOCK TABLES, REFERENCES and TRIGGER on the database, and the same privileges that setup:upgrade needs, which is why a separate mg_migrate exists and the shop runs as mg_app with data privileges only. The Job script ends with two counts that you compare with the old server in the verification step. From a bastion the commands are the same with mysql run directly.

#!/usr/bin/env bash
# import-from-bastion.sh - the same import from a host inside the VPC.
set -euo pipefail

: "${DB_HOST:?set DB_HOST to the RDS endpoint}"
: "${DUMP:?set DUMP to the dump file}"
CA="${CA:-global-bundle.pem}"

[ -f "$CA" ] || curl -fsSL -o "$CA" https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem

# the password is read from the environment by the client, not typed on the command line
export MYSQL_PWD
MYSQL_PWD="$(aws secretsmanager get-secret-value --secret-id pocketcoffee/prod/magento \
  --query SecretString --output text | jq -r '.db_migrate_password')"

zcat "$DUMP" | mysql --host="$DB_HOST" --user=mg_migrate \
  --default-character-set=utf8mb4 --ssl-mode=VERIFY_IDENTITY --ssl-ca="$CA" pocketcoffee

The key db_migrate_password is the one of the secret on the managed database page. For the import time, count on the dump time or more: index maintenance on RDS is the slow part, and a larger instance class for the import day, resized down afterwards, is a legitimate trick.

AWS DMS for a shorter freeze

A dump and import takes the freeze as long as the dump, the upload and the import together. When that exceeds the maintenance window the business accepts, AWS Database Migration Service copies the database once (full load) and then replays the changes of the source (change data capture) until you stop the task at the cutover. The source needs binary logging in ROW format with a retention longer than the full load, a replication user and network access from the replication instance; those requirements, the limits of DMS (it copies tables and primary keys, but not secondary indexes, views, triggers or routines the way a dump does) and the schema-first method are explained on the WordPress page and on Architecture, Migration and Hybrid. They apply unchanged to the Magento tables.

What Magento adds:

  • Create the schema with a --no-data dump (with the same DEFINER filter, and with --routines --events --triggers) before the task starts, so every index, default, foreign key and trigger exists; DMS then only loads rows. Magento’s schema has many foreign keys, so DMS must load with them disabled or in an order that respects them, or the target must be configured accordingly; read the DMS documentation for MySQL targets and test it.

  • Binary logging on a shared or managed host is usually not available. DMS is a method for a VM or dedicated server you control.

  • DMS does not make two writers safe. You still freeze the shop at the end. It shortens the copy, not the freeze rule.

  • Compare, do not trust. After the task stops, compare row counts and a checksum of the tables that matter (sales_order, sales_order_item, quote, customer_entity, core_config_data, catalog_product_entity) between the two databases (see Verifying that nothing was lost).

Base URLs and the settings bound to the old host

Magento stores its URLs in core_config_data, per scope, and the Admin, the URL generation, the cookies and the redirects read them from there. A URL that points to the old environment is the classic result of a copied database: the storefront redirects to the old host, or the admin form posts to it. There are two cases for the Pocket Coffee shop, and the SQL below handles both.

  • The domain does not change (https://www.pocketcoffee.example/ on the old server and on the cluster, DNS decides which one answers). The base URL rows stay as they are. Only the media URLs change, because the media now come from https://media.pocketcoffee.example/. This is the preferred case: you test the new stack with curl --resolve (see Verifying that nothing was lost) against the real host name, and the cutover is a DNS change, nothing else.

  • The domain changes (a rehearsal on https://staging.pocketcoffee.example/, a dump taken from a local environment such as the .test hosts of the local environments, or an apex-to-www change). Every base URL row of every scope must be rewritten, and so must the hard-coded absolute URLs in content.

The paths, read in the system.xml of the Store and Backend modules and in the Adobe documentation, are:

Path Meaning

web/unsecure/base_url, web/secure/base_url

The base URLs of the storefront for HTTP and HTTPS, per scope. They end with /

web/unsecure/base_link_url, web/secure/base_link_url

The base URL for links; by default the placeholders {{unsecure_base_url}} and {{secure_base_url}}, so usually no rewrite is needed

web/unsecure/base_static_url, web/secure/base_static_url

The URL of pub/static; empty or a placeholder unless static files come from a CDN (see Cloud: Varnish and Full Page Cache on Kubernetes)

web/unsecure/base_media_url, web/secure/base_media_url

The URL of the media; set to https://media.pocketcoffee.example/ when CloudFront serves the bucket

web/secure/use_in_frontend, web/secure/use_in_adminhtml

Force HTTPS in the storefront and in the Admin; both 1 in the cluster

web/secure/offloader_header

The header the TLS-terminating proxy sends (X-Forwarded-Proto), without which Magento sees plain HTTP behind the load balancer and redirects in a loop

web/cookie/cookie_domain

The cookie domain; if it holds the old host, customers cannot log in on the new one

web/url/redirect_to_base, web/url/use_store

Redirect to the base URL and put the store code in the URL; check that they match the behaviour you want

admin/url/use_custom, admin/url/custom, admin/url/use_custom_path, admin/url/custom_path

A custom Admin URL and path, if the old shop set them; the cluster routes the admin by the frontName of env.php (admin_pocketcoffee, see the Helm page)

-- urls.sql - rewrite the base URLs after the import. Run with the three variables set first:
--   SET @old_base = 'https://www.pocketcoffee.example/';  SET @new_base = 'https://www.pocketcoffee.example/';
--   SET @new_media = 'https://media.pocketcoffee.example/';
-- 1. what is stored now (paths and values of URL settings, no secrets)
SELECT scope, scope_id, path, value
  FROM core_config_data
 WHERE path REGEXP '^web/(un)?secure/(base_url|base_link_url|base_static_url|base_media_url|use_in_frontend|use_in_adminhtml|offloader_header)$'
    OR path IN ('web/cookie/cookie_domain', 'web/url/redirect_to_base', 'web/url/use_store',
                'admin/url/use_custom', 'admin/url/custom', 'admin/url/use_custom_path', 'admin/url/custom_path')
 ORDER BY path, scope, scope_id;

-- 2. rewrite the base URL rows of every scope (no-op when old and new are the same)
UPDATE core_config_data
   SET value = REPLACE(value, @old_base, @new_base)
 WHERE path IN ('web/unsecure/base_url', 'web/secure/base_url', 'web/unsecure/base_link_url', 'web/secure/base_link_url')
   AND value LIKE CONCAT(@old_base, '%');

-- 3. the media URL comes from the CDN: replace the default-scope rows
DELETE FROM core_config_data
 WHERE scope = 'default' AND scope_id = 0
   AND path IN ('web/unsecure/base_media_url', 'web/secure/base_media_url');
INSERT INTO core_config_data (scope, scope_id, path, value)
VALUES ('default', 0, 'web/unsecure/base_media_url', @new_media),
       ('default', 0, 'web/secure/base_media_url', @new_media);

-- 4. rows of narrower scopes that override the media URL are listed, not changed: look at them
SELECT scope, scope_id, path, value FROM core_config_data
 WHERE path IN ('web/unsecure/base_media_url', 'web/secure/base_media_url') AND scope <> 'default';

-- 5. content with the old absolute URL (CMS and category descriptions; products and widgets are checked the same way)
SELECT 'cms_page' AS tbl, COUNT(*) AS rows_with_old_url FROM cms_page WHERE content LIKE CONCAT('%', @old_base, '%')
UNION ALL SELECT 'cms_block', COUNT(*) FROM cms_block WHERE content LIKE CONCAT('%', @old_base, '%');

Notes on the statements:

  • Rows hold the scope. scope is default, websites or stores, and scope_id is the id of the website or store. A narrower scope overrides the default, so a rewrite that only touches the default rows leaves the es, en and us store views pointing at the old host. The UPDATE does not look at the scope for that reason.

  • The unique key. core_config_data has a unique key on scope, scope_id and path, so the DELETE followed by an INSERT cannot create a duplicate. The script does not use INSERT ... ON DUPLICATE KEY UPDATE because its portable forms differ between MySQL 8.4 and MariaDB.

  • Content with absolute URLs. Editors paste the full URL into CMS pages and blocks; the media directives ({{media url="..."}}) are relative and need no change. Count first; replace in a rehearsal with a reviewed statement, because a blind REPLACE of the host also changes links to other pages that you may want to keep as they are.

# after the first pod is up (see the runbook), the same values through the CLI
kubectl -n pocketcoffee exec deploy/pocketcoffee-web -c php-fpm -- bin/magento config:show web/unsecure/base_url
kubectl -n pocketcoffee exec deploy/pocketcoffee-web -c php-fpm -- bin/magento config:show --scope=stores --scope-code=es web/secure/base_url
kubectl -n pocketcoffee exec deploy/pocketcoffee-web -c php-fpm -- bin/magento config:show web/secure/base_media_url

A value written with config:set --lock-env goes to env.php and one written with --lock-config to config.php, and both make the field read-only in the Admin. Neither is what you want for URLs on a cluster whose env.php is a read-only ConfigMap: keep plain config:set (database) for values that editors may change and put environment-specific settings in the env.php template. A value in env.php or config.php takes precedence over the same path in core_config_data, so an old system section copied from the old server’s env.php would silently override the rows you just corrected; see Rebuilding env.php.

The encryption key and rebuilding env.php

app/etc/env.php is the file that makes the image a copy of this shop, and the one place where a careless migration destroys data. Never copy it as it is, and never create it by running the installer.

Why the key matters. Magento encrypts some values before it stores them, with the key in crypt/key of env.php: the values of configuration fields that use the encrypted backend model (payment gateway credentials, SMTP passwords and similar), the secrets of extensions that use EncryptorInterface, and anything else your modules encrypt. The value has the form <key version>:<cipher>:<data> (read in Magento\Framework\Encryption\Encryptor; the current cipher is 3, ChaCha20-Poly1305). A new key cannot decrypt them. The result is not an error at the import but a payment method that stops authorising, a mail server that rejects the stored password, or a decryption exception at the first use. Keep crypt/key identical to the old shop’s value. In the Pocket Coffee setup it is only a secret reference: the value lives in the secret pocketcoffee/prod/magento under the key crypt_key, reaches the pods as the environment variable MAGENTO_CRYPT_KEY, and is never written into a manifest, an image, a ticket or a chat. How the first-install Job handles a key for a shop that starts empty is on the Helm page; it is not used for a migration.

The value can hold more than one key. Encryptor splits the value on whitespace (preg_split('/\s+/s', trim(...))) and uses the last key for new encryption and the earlier ones, by version number, for old data. A shop that changed its key from the Admin has several keys on separate lines in crypt/key; copy all of them, in order, as the secret crypt_key. The inventory prints how many the old file holds.

Which values can be checked. You can find the encrypted rows without reading them: count the values of core_config_data that have the shape of an encrypted value, list only their paths, and after the migration open one of them through the feature that uses it (a test payment in the sandbox of the provider, a test mail).

-- candidates for encrypted values (paths only, never the values); compare the list on the old and the new database
SELECT scope, scope_id, path
  FROM core_config_data
 WHERE value REGEXP '^[0-9]+:[0-3]:'
 ORDER BY path, scope, scope_id;

How to move the key. It crosses from the old server to Secrets Manager through a channel that does not leave a trace: an SSH stream into a file in memory (umask 077, on a tmpfs such as /dev/shm) on an administrator’s workstation, then put-secret-value, then the file is deleted. The key is never an argument of a command (arguments appear in the process list and in shell history), never printed and never stored in a repository. The script reads the key from the old server’s env.php by key path and handles the value only inside the pipe; the jq call keeps the other keys of the secret.

#!/usr/bin/env bash
# copy-crypt-key.sh - copy crypt/key of the old shop into the secret pocketcoffee/prod/magento (key crypt_key).
# Run on an administrator workstation with AWS credentials for the secret and SSH access to the old server.
# The value is neither printed nor passed as an argument; it passes through a private file in memory.
set -euo pipefail
umask 077

: "${OLD_HOST:?set OLD_HOST to the old server (user@host)}"
OLD_DIR="${OLD_DIR:-/var/www/html}"
SECRET_ID="${SECRET_ID:-pocketcoffee/prod/magento}"
WORK="$(mktemp -d /dev/shm/crypt.XXXXXX)"
trap 'rm -rf "$WORK"' EXIT

# 1. the key (every line of it) travels over SSH into a file
# shellcheck disable=SC2029  # OLD_DIR is meant to expand on the client side
ssh "$OLD_HOST" "cd '$OLD_DIR' && php -r '\$e = require \"app/etc/env.php\"; echo \$e[\"crypt\"][\"key\"];'" > "$WORK/crypt_key"
test -s "$WORK/crypt_key" || { echo "empty crypt/key, stop" >&2; exit 1; }

# 2. merge it into the existing secret (the other keys stay) and write a new version
aws secretsmanager get-secret-value --secret-id "$SECRET_ID" --query SecretString --output text > "$WORK/secret.json"
jq --rawfile key "$WORK/crypt_key" '.crypt_key = $key' "$WORK/secret.json" > "$WORK/secret-new.json"
aws secretsmanager put-secret-value --secret-id "$SECRET_ID" --secret-string "file://$WORK/secret-new.json" > /dev/null

# 3. only the length is shown, to compare with the same measurement on the old server
printf 'crypt_key stored, %s bytes\n' "$(wc -c < "$WORK/crypt_key" | tr -d ' ')"

The script shows only the length, which proves that something was stored and lets you compare it with the same measurement on the old server; it never shows a part of the key. Restrict secretsmanager:PutSecretValue on that secret to the people who run the migration and remove it afterwards. The key rotation that follows the migration is part of the operations page, Cloud: Autoscaling, Observability, Backups and Security; do not rotate during the migration.

What else env.php contains, and where each part comes from in the cluster. The new file is the template in the ConfigMap pocketcoffee-env-php, read with getenv() (see the Helm page). Compare it, key path by key path, with the output of the inventory:

Key path in env.php On the old server In the cluster

crypt/key

The key of the shop

Identical, from the secret reference MAGENTO_CRYPT_KEY

install/date

The installation date of the old shop

Keep the old value. DeploymentConfig::isAvailable() is true only when it is set, so without it the application treats the shop as not installed; it must be present

db/table_prefix, db/connection/default

The old prefix and the local database

The same prefix as the dump (empty for Pocket Coffee; do not rename tables), the RDS endpoint and the user mg_app; no checkout or sales split connection (the split database is deprecated since 2.4.2)

backend/frontName

The old Admin path, often admin

admin_pocketcoffee from MAGENTO_BACKEND_FRONTNAME; the old path must no longer answer

session, cache, lock

Files, or Redis on the same host

Valkey replication groups pocketcoffee-prod-session and pocketcoffee-prod-cache, TLS and AUTH token from the secret

queue

The db connection or the local RabbitMQ

The Amazon MQ broker pocketcoffee-prod-mq

remote_storage

file (absent)

The driver aws-s3 with the bucket pocketcoffee-prod-media, see Moving the media

MAGE_MODE

Often production, sometimes developer on a staging server

production, always

cache_types

The cache types enabled

Keep all of them enabled; cache:enable and cache:disable write this block, which a read-only file cannot take, so decide it in the template

downloadable_domains

The domains allowed for downloadable-product links

Keep the list and add the new media host if downloadable products use absolute URLs

system

Settings written with config:set --lock-env

Review each: the old values name the old hosts (for example catalog/search/opensearch_server_hostname); keep only what still holds, and take hosts and passwords from the environment

config.php is not copied either. Run bin/magento app:config:dump on a configured reference system (it needs an installed database, so it is not a step of the image build itself; the build consumes the committed file, see Cloud: Container Image and Pipeline Build); it writes the shared, non-sensitive configuration (modules, scopes, themes and system settings that are not environment-specific) into app/etc/config.php, which is committed, while the sensitive and system-specific settings stay out of it. Adobe states that config.php is the file shared by all instances and env.php is the environment-specific one that is not committed. A config.php produced from a production dump is the evidence that the modules list and the website and store codes of the image match the database; if the image has a module that the database does not know, setup:db:status reports it and the upgrade Job handles it.

Moving the media

pub/media holds what the shop writes: the product and category images (catalog/), the files of the editor (wysiwyg/), customer uploads, downloadable-product files, the import folder and the generated caches. The target is the S3 bucket pocketcoffee-prod-media, used through the AWS S3 driver of Magento’s remote storage (the design, the CloudFront distribution and the local paths that stay per pod are on Cloud: Media and Remote Storage). Two things move: the files, and the information that the shop now reads them from S3.

What not to copy. The folder catalog/product/cache holds the resized product images that Magento generates from the originals on demand; they are regenerated, they are usually the largest part of pub/media and copying them only moves bytes that the first visitors would regenerate anyway. tmp/ holds temporary uploads. Copy everything else. Look at the real tree in the inventory: an extension may keep files under its own folder.

Two ways to copy.

  • aws s3 sync from the old server (or from a machine that mounts it). It resumes after an interruption, skips what is already in the bucket, and takes --exclude patterns. It needs an IAM identity with write access to one bucket and no Kubernetes access.

  • bin/magento remote-storage:sync on a Magento instance whose configuration already uses the S3 driver and that still has the local files. Adobe documents the command without options. In the source (Magento\RemoteStorage\Model\Synchronizer) it walks the media directory, creates in the bucket the folders and files that are missing, skips those that exist, and has no exclude option, so it would also upload the resized-image cache.

#!/usr/bin/env bash
# sync-media-to-s3.sh - copy pub/media of the old server to the bucket of the new stack.
# Run it for the pre-copy (days before) and again after the freeze: the second run moves only the delta.
set -euo pipefail

SRC="${SRC:-/var/www/html/pub/media}"
BUCKET="${BUCKET:-pocketcoffee-prod-media}"
REGION="${REGION:-eu-west-1}"

# dry run first: shows what would be copied and what the size will be
aws s3 sync "$SRC" "s3://$BUCKET" --region "$REGION" \
  --exclude "catalog/product/cache/*" \
  --exclude "tmp/*" \
  --exclude ".htaccess" --exclude "*/.htaccess" \
  --dryrun | tail -n 5

if [ "${DRY_RUN_ONLY:-0}" = "1" ]; then exit 0; fi

aws s3 sync "$SRC" "s3://$BUCKET" --region "$REGION" \
  --exclude "catalog/product/cache/*" \
  --exclude "tmp/*" \
  --exclude ".htaccess" --exclude "*/.htaccess"

# the same count on both sides (the excluded paths are not counted)
echo "local files:  $(find "$SRC" -type f -not -path '*/catalog/product/cache/*' -not -path '*/tmp/*' -not -name .htaccess | wc -l)"
echo "bucket files: $(aws s3 ls "s3://$BUCKET" --recursive --region "$REGION" | wc -l)"

The script uses the credentials of the machine’s AWS profile (a role, for the old server a temporary identity from aws sso or an assumed role); never put access keys into the script. The .htaccess files of pub/media protect folders on Apache (downloadable files are denied to direct access); they mean nothing on S3 and must not be copied. Protect the same folders on the new side: the downloadable-product files, customer uploads and the import folder must not be readable through the public media host; the CloudFront and bucket-policy design that does it is on the media page. Test one downloadable product after the move.

Magento then has to read the files from the bucket. The settings are documented by Adobe and are part of the new env.php (remote_storage) built from the environment; the commands are shown here because the rehearsal runs them once against a copy.

# on a copy of the shop that has the files locally and the database imported
bin/magento setup:config:set --remote-storage-driver="aws-s3" \
  --remote-storage-bucket="pocketcoffee-prod-media" --remote-storage-region="eu-west-1" -n
bin/magento config:set system/media_storage_configuration/media_database 0
bin/magento remote-storage:sync          # uploads what the bucket does not have yet
bin/magento cache:flush

No access keys: the optional --remote-storage-key and --remote-storage-secret are omitted, so the SDK uses the pod’s IAM role (pocketcoffee-prod-magento, Pod Identity), as Adobe’s page allows. The Adobe page states two constraints that matter here: generated or cached images are written on the local file system, so pub/media and similar folders need the permissions they have with local storage, and code must use Magento’s file adapters instead of native PHP file functions, because the files are not on the disk. That is why extensions of the old shop that write into pub/media with file_put_contents must be found in the inventory and tested in the rehearsal. The remote-storage module also caches file metadata (the module ships a cached adapter); flush the Magento cache after a bulk copy that bypassed Magento. How long that cache keeps entries is not documented in the pages read for this one: verify it in the rehearsal by copying a file with aws s3 cp and reading it through the shop.

A variant for a shorter freeze. Switch the old server to the S3 driver a few days before the cutover (the same two commands on the old server, a role or an identity that can write to the bucket): Magento itself uploads the existing files with remote-storage:sync and every new upload goes to the bucket from that moment. The old server and the cluster then share one bucket and the cutover has no file delta. The cost is a change to the production shop before the move (rehearse it) and the rollback, which then also uses the bucket: the old server keeps working because it uses the same driver.

Search and indexes

The OpenSearch index is a derived structure: nothing of it is copied. The imported database already holds the MySQL-based index tables (catalog_product_index_* and others) and the state of the indexers, but the search index of the old server is not in the cluster’s OpenSearch domain, so the storefront has no search results and no layered navigation until it is built.

Engine and connection. The engine is the configuration path catalog/search/engine; the connection values for OpenSearch are catalog/search/opensearch_server_hostname, opensearch_server_port, opensearch_index_prefix, opensearch_enable_auth, opensearch_username and opensearch_password (all read in the system.xml and di.xml of the OpenSearch module; the hostname and the password are declared system-specific there). Three situations occur:

  • The old shop already uses opensearch on localhost:9200. The engine is right and the rows hold the old host. Remove the host, port and credential rows from the database and give the new values from the environment (the system section of the env.php template, see the OpenSearch page), or the old localhost wins whenever the template does not override it.

  • The old shop uses elasticsearch7 or elasticsearch8. Adobe’s 2.4.9 requirements list OpenSearch, so the engine must change to opensearch; bin/magento config:set catalog/search/engine opensearch writes it. Do it during the rehearsal and read the result: the installer of Mage-OS 3.6.0 still accepts elasticsearch8, but that is not what the 2.4.9 requirements document.

  • The index prefix: use one prefix for the shop (pocketcoffee) and a different one in the rehearsal, so both can live in the same domain without clashing.

# on the first pod, after the import
bin/magento config:show catalog/search/engine
bin/magento config:set catalog/search/engine opensearch
bin/magento indexer:status                 # every indexer should show "Reindex required" or a valid state

The reindex. bin/magento indexer:reindex rebuilds every indexer, the search index among them. It is the slowest step of the freeze for a large catalogue; run it once in the rehearsal and write the time down. It runs as a Job, not in kubectl exec, so a dropped connection does not stop it: use the Job pocketcoffee-reindex of the chart and run it the way the Cloud: Cron, Queue Consumers and Indexers page describes. After it, switch the indexers back to "Update by Schedule" if the old shop used it (indexer:status and indexer:show-mode show the modes that came with the database), and let the cron on the cluster, not the freeze, process the changelogs. Two behaviours to know:

  • The old server’s changelog tables came with the dump. Reindexing everything makes them irrelevant, so you do not need to wait for the cron to drain them.

  • Before the reindex finished, the Admin can still be used for checks; the storefront search is empty. That is why the reindex belongs to the freeze, and the DNS switch comes after it.

The cache of the cluster starts empty (a new Valkey instance and an empty Varnish), so there is nothing stale to purge; run bin/magento cache:flush once after the import and the URL statements, before the first real request, because the configuration cache is built from core_config_data.

Orders during the cutover

Maintenance mode is the freeze

Magento has no read-only mode. The closest thing is maintenance mode: bin/magento maintenance:enable creates var/.maintenance.flag, every request answers 503 (a static page), and --ip=<address> (repeat the option for several addresses; they are written to var/.maintenance.ip) lets chosen addresses through. The Admin is blocked too, apart from the exempt addresses. The freeze also needs:

  • The scheduler stopped. bin/magento cron:remove removes the Magento block of the crontab; a cron job that is running must finish first. SELECT COUNT(*) FROM cron_schedule WHERE status = 'running' tells.

  • The queue drained. With the db queue connection wait until no message is new, in progress or to be retried (status 2, 3 and 5). With amqp wait until the queues of the old RabbitMQ are empty. Asynchronous operations (bulk actions, product import and the consumers started by cron) must finish before the dump, or they are lost or replayed twice.

  • No admin session writing. A catalogue manager editing products at the time of the dump loses the edit. Tell the team before the freeze.

#!/usr/bin/env bash
# freeze-old-server.sh - run on the old server at T-0, as the owner of the Magento files.
set -euo pipefail

MAGENTO_DIR="${MAGENTO_DIR:-/var/www/html}"
cd "$MAGENTO_DIR"
MYSQL=(mysql --defaults-extra-file="$HOME/.my.cnf" --batch --skip-column-names)
: "${DB_NAME:?set DB_NAME}"

# 1. stop the storefront and the Admin; the operator's address may pass (list more with repeated --ip)
php bin/magento maintenance:enable ${OPERATOR_IP:+--ip="$OPERATOR_IP"}
php bin/magento maintenance:status

# 2. no new cron runs; wait for the running ones
php bin/magento cron:remove
for _ in $(seq 1 60); do
  running="$("${MYSQL[@]}" -e "SELECT COUNT(*) FROM cron_schedule WHERE status = 'running';" "$DB_NAME")"
  [ "$running" = "0" ] && break
  sleep 10
done
echo "cron jobs still running: $running"

# 3. the database queue must be empty: statuses 2 (new), 3 (in progress) and 5 (retry required)
pending="$("${MYSQL[@]}" -e "SELECT COUNT(*) FROM queue_message_status WHERE status IN (2, 3, 5);" "$DB_NAME")"
echo "queue messages pending: $pending"
[ "$pending" = "0" ] || { echo "drain the queue before the dump" >&2; exit 1; }

# 4. the numbers to compare after the import
"${MYSQL[@]}" -e "SELECT COUNT(*) FROM sales_order; SELECT MAX(entity_id) FROM sales_order; SELECT COUNT(*) FROM customer_entity;" "$DB_NAME"

$DB_NAME is the variable of the dump script. The ${OPERATOR_IP:+...} expansion adds the option only when the variable is set. If a consumer runs outside cron on the old server (a systemd unit or a supervisor program), stop it too: the inventory shows only the crontab.

The payment in flight

Pocket Coffee’s PocketCoffee_Pay module uses an external payment provider whose notifications arrive some seconds or minutes after the customer paid (the provider calls a URL of the shop). That makes the freeze delicate:

  • A customer who paid at 09:58 and whose notification arrives at 10:02, while the shop is in maintenance mode, gets a 503, and the provider retries later, at the new stack or at the old one depending on DNS. Check the retry policy of your provider (how many times and how long) in its documentation; it is not part of Magento.

  • The notification URL is a path of the shop host (not of a different host), so it follows DNS: after the switch it reaches the cluster, whose database contains every order of the dump. An order that was placed after the dump exists on the old server only (see the next section).

  • After the switch, list the orders of the last hours in pending_payment or payment_review state on the new side and reconcile them with the provider’s dashboard, one by one. Do not capture payments from the Magento side on the strength of the order state alone.

  • A customer who is at the payment page of the provider when the freeze starts returns to a shop that answers 503; the order stays as the quote. After the cutover the cart is still there (quotes are in the database) and the customer pays again; the provider’s reference of the first attempt prevents a double charge only if the module uses idempotency, which depends on your module and is a rehearsal item.

DNS and its TTL

The DNS record of www.pocketcoffee.example points to the old server. Two days before the cutover lower its TTL to 60 seconds, after the old TTL has expired everywhere (it is not enough to lower it the day before if it was one day). At the switch, change the record to the new load balancer (see Route 53 and Certificates); the old address stays reachable by IP for the rollback. Resolvers that ignore the TTL exist; the freeze on the old server is what makes them harmless, because an old server in maintenance mode answers 503, not a second copy of the shop. Raise the TTL again when the shop has been stable for a day. The media host media.pocketcoffee.example is a new name and has no TTL problem; create it and test it before the cutover.

Two writers is the failure to avoid

If the new shop starts taking orders while the old one still does, the shop has two sets of order numbers: both count from the same increment in their sequence_order_* tables and both will create "000000123". The shop then needs a manual merge of two databases, which is the worst day of a migration. The rules that prevent it:

  • The old server leaves maintenance mode only for a rollback, and only after the new stack is stopped (see Rollback plan).

  • The cluster does not start pocketcoffee-web before the import is verified; the load balancer and the DNS are the last step, after curl --resolve checks.

  • Test orders made on the new stack before the switch go to a copy of the database, never to the production one; a test order on the production database after the import is an order of the shop.

Cutover runbook

The times are an example for a shop whose dump takes ten minutes, whose import takes twenty and whose full reindex takes forty; replace them with the numbers of your rehearsal. Each step has an owner and a stop condition; "stop" means that the cutover is abandoned and the old server leaves maintenance mode (see Rollback plan).

When Step Stop condition

T-7 d

Rehearsal complete, with timings. Inventory differences committed and the image rebuilt. crypt_key stored in the secret. Pre-copy of pub/media to the bucket.

The rehearsal shop shows different totals than the old shop, or a payment method cannot decrypt its credentials

T-2 d

DNS TTL of www.pocketcoffee.example lowered to 60 seconds. Provider told. Team told.

The old TTL has not expired

T-24 h

Second media pre-copy. Cluster ready: web.replicas: 0, cron suspended, consumers at zero, RDS empty and snapshot-ready. Rollback owner confirmed.

Any of the checks of the previous point fails

T-0

freeze-old-server.sh: maintenance mode, cron removed, queue drained.

The queue does not drain

T+0:10

dump.sh, upload the dump to pocketcoffee-prod-migration. Final sync-media-to-s3.sh.

The dump is truncated or the media counts differ

T+0:30

Job pocketcoffee-import: import, URL statements. Compare the two counts with the ones printed on the old server.

The counts differ

T+0:50

Scale the web Deployment to one pod. config:show of the base URLs. catalog/search/engine is opensearch. setup:db:status; run the Job pocketcoffee-migrate if the database asks for an upgrade.

A URL points to the old host; setup:db:status reports a module that is not in the image

T+1:00

Job pocketcoffee-reindex. Then cache:flush. Checks through curl --resolve (see below).

The reindex fails or a check fails

T+1:40

Scale web to its production size, start Varnish and the admin Deployment, unsuspend pocketcoffee-cron, start the consumers. Switch DNS.

Health checks of the pods fail

T+1:45

Watch: orders, errors, payment notifications, search, the first customer login. The first real order is placed and checked by the team.

Orders fail to be placed

T+2 d to T+7 d

Reconcile the orders and payments of the first days. Raise the TTL. Keep the old server in maintenance mode (or powered off) and untouched.

Anything that the old server’s data is still needed for

#!/usr/bin/env bash
# cutover-new-stack.sh - the steps on the cluster between the final dump and the DNS switch.
set -euo pipefail

NS="${NS:-pocketcoffee}"
EXEC=(kubectl -n "$NS" exec deploy/pocketcoffee-web -c php-fpm --)

# 1. import and URL statements (the Job manifest is on this page)
kubectl -n "$NS" apply -f pocketcoffee-import.yaml
kubectl -n "$NS" wait --for=condition=complete job/pocketcoffee-import --timeout=3600s
kubectl -n "$NS" logs job/pocketcoffee-import -c import | tail -n 5

# 2. one pod, to read the configuration through the application
helm upgrade pocketcoffee ./pocketcoffee-magento --namespace "$NS" --reuse-values --set web.replicas=1
kubectl -n "$NS" rollout status deploy/pocketcoffee-web --timeout=600s
"${EXEC[@]}" bin/magento config:show web/secure/base_url
"${EXEC[@]}" bin/magento config:show web/secure/base_media_url
"${EXEC[@]}" bin/magento config:set catalog/search/engine opensearch
"${EXEC[@]}" bin/magento setup:db:status

# 3. the full reindex runs as the chart's Job, started the way the indexers page describes
kubectl -n "$NS" wait --for=condition=complete job/pocketcoffee-reindex --timeout=7200s
"${EXEC[@]}" bin/magento indexer:status
"${EXEC[@]}" bin/magento cache:flush

# 4. production size, then the scheduler
helm upgrade pocketcoffee ./pocketcoffee-magento --namespace "$NS" --reuse-values --set web.replicas=3
kubectl -n "$NS" rollout status deploy/pocketcoffee-web --timeout=600s
kubectl -n "$NS" patch cronjob pocketcoffee-cron -p '{"spec":{"suspend":false}}'

The script assumes the chart of the Helm page, whose values and resource names it uses (web.replicas, pocketcoffee-web, pocketcoffee-reindex, pocketcoffee-cron). The CronJob must not run before the cutover: suspend it with kubectl patch (the same command with true) after the first Helm install, or with the equivalent value if your chart has one, and note that a later helm upgrade can revert a patch made by hand, so set the final state in the values afterwards. The config:set of the engine runs in a pod whose root file system is read-only: it writes to the database and to var/, which is an emptyDir; if the command asks for more, run it in the migration Job of the chart instead and treat that as a result of the rehearsal.

Verifying that nothing was lost

Two sets of checks: numbers that must be equal between the old and the new database, and requests through the new stack with the real host name before DNS points to it.

#!/usr/bin/env bash
# compare-counts.sh - print counts that must be equal on the old and the new database; run once on each, diff the outputs.
set -euo pipefail

: "${DB_NAME:?set DB_NAME}"
MYSQL=(mysql --batch --skip-column-names "$@")   # connection options (host, user, TLS) are passed as arguments, never the password

for table in sales_order sales_order_item sales_invoice quote customer_entity customer_address_entity \
             catalog_product_entity catalog_category_entity url_rewrite cms_page cms_block core_config_data \
             admin_user store store_website; do
  printf '%s\t%s\n' "$table" "$("${MYSQL[@]}" -e "SELECT COUNT(*) FROM \`$table\`;" "$DB_NAME")"
done
printf 'max_order_id\t%s\n' "$("${MYSQL[@]}" -e "SELECT COALESCE(MAX(entity_id), 0) FROM sales_order;" "$DB_NAME")"
printf 'max_increment_id\t%s\n' "$("${MYSQL[@]}" -e "SELECT COALESCE(MAX(increment_id), '') FROM sales_order;" "$DB_NAME")"
printf 'last_order_at\t%s\n' "$("${MYSQL[@]}" -e "SELECT COALESCE(MAX(created_at), '') FROM sales_order;" "$DB_NAME")"
printf 'encrypted_value_paths\t%s\n' "$("${MYSQL[@]}" -e "SELECT COUNT(*) FROM core_config_data WHERE value REGEXP '^[0-9]+:[0-3]:';" "$DB_NAME")"

The two max_ lines catch the order that exists on one side only. The password reaches the client through the MYSQL_PWD environment variable or a defaults file, as in the import script. Then the requests, resolving the real host name to the load balancer of the new stack with --resolve, which tests TLS, routing, the Varnish layer, the media host and the Admin without touching DNS:

#!/usr/bin/env bash
# check-new-stack.sh - requests against the new stack with the production host name, before the DNS switch.
set -euo pipefail

: "${NEW_IP:?set NEW_IP to an address of the new load balancer}"
HOST=www.pocketcoffee.example
RESOLVE=(--resolve "$HOST:443:$NEW_IP")

for path in '/' '/health_check.php' '/customer/account/login/' '/catalogsearch/result/?q=espresso' \
            '/admin_pocketcoffee/'; do
  code="$(curl -sS -o /dev/null -w '%{http_code}' "${RESOLVE[@]}" "https://$HOST$path")"
  printf '%s %s\n' "$code" "$path"
done

# the redirect target of the old Admin path must not exist and the store views must not redirect to another host
curl -sSI "${RESOLVE[@]}" "https://$HOST/admin/" | head -n 1
curl -sSI "${RESOLVE[@]}" "https://$HOST/" | grep -i '^location' || echo "no redirect from /"

# a media file through CloudFront, the same file the product page uses
curl -sSI "https://media.pocketcoffee.example/${MEDIA_SAMPLE:?set MEDIA_SAMPLE to a path in pub/media}" | head -n 1

By hand, in this order: log in to the Admin as an administrator and open the configuration of the payment method (it decrypts the stored credentials, which proves the key), place a test order with the sandbox of the provider on the rehearsal copy, open a product page of each store view, search for a product (OpenSearch), open the sales order grid and a recent order, and send a test mail from the order page. Compare the search results count of one query with the old shop.

Rollback plan

The rollback is possible because the old server is untouched. What it costs depends on how much time passed since the switch.

When the problem appears Rollback What is lost

Before the DNS switch (a failed check)

Stop. Disable maintenance mode on the old server (php bin/magento maintenance:disable), reinstall its cron (php bin/magento cron:install) and restart anything you stopped. The cluster stays at zero replicas.

Nothing: the old server took no orders during the freeze, the customers saw 503

After the switch, before any real order

Point DNS back (the TTL is 60 seconds), scale the new web Deployment to zero, leave maintenance mode on the old server, reinstall its cron.

Nothing, except the orders that the team placed as tests (delete them or let them age)

After the switch, with orders on the new stack

Prefer to roll forward: the new stack is the shop and the problem is fixed there. If you must go back, put the new stack into maintenance mode first (the same two commands, in a pod), dump the new database and import into the old server only the new orders, customers and quotes with a script that you have rehearsed; or accept that the orders of the window are re-entered from the payment provider’s records.

Time, and possibly the mapping of the order numbers

After a day or more

There is no rollback: roll forward. The old server is a source of historical data, not of a running shop.

(not applicable)

Three things make the rollback cheap and must be decided before the cutover:

  • The old server keeps its dump-time state. Do not run setup:upgrade, indexer:reindex or a cache flush on it after the freeze; leave it as it was at T-0, in maintenance mode, for seven days. Back its disk up at the freeze (a snapshot of the volume, or a copy of the virtual machine) and remove that snapshot only when the new shop has had a quiet week.

  • The media after the switch. New uploads on the cluster land in the bucket, not on the old disk. If the old server must serve again, copy the delta back with aws s3 sync s3://pocketcoffee-prod-media /var/www/html/pub/media --exclude "catalog/product/cache/*" (the opposite direction of the script above); if you used the variant in which the old server also uses the bucket, there is nothing to copy.

  • Payment notifications. If the provider’s callback must reach the old server again, its URL is the same host name, so DNS decides. Ask the provider whether notifications retried during the switch can be re-sent, and have its dashboard ready for reconciliation.

Common errors and pitfalls

Symptom Cause Fix

The import stops with error 1227 ("you need (at least one of) the SUPER or SET_USER_ID privilege(s)")

A trigger, view or routine in the dump has a DEFINER that is not the importing user

Remove the definers with the filter of the dump script; count the remaining ones with grep -c DEFINER

The import stops with "you do not have the SUPER privilege and binary logging is enabled"

log_bin_trust_function_creators is 0 in the RDS parameter group

Set it as the managed database page describes; it is a parameter-group setting, not a session setting

The storefront redirects to the old host, or the Admin loops

URL rows of a narrower scope, or web/cookie/cookie_domain or web/secure/offloader_header still describe the old setup

List the rows with the SELECT of urls.sql; set X-Forwarded-Proto as the offloader header

Images are missing or come from the old host

The media URL was not changed, the files are not in the bucket, or the bucket policy does not allow CloudFront

Check web/secure/base_media_url, aws s3 ls, and the media page

A payment method fails after the migration, or a "decryption" exception appears

crypt/key differs from the old one, or only the last of several keys was copied

Restore the full old value in the secret; never generate a key

The application redirects to the installer or says it is not installed

install/date is missing from the rendered env.php

Keep the old value in the template

The search is empty

The reindex did not run, the engine is not opensearch, or the host in core_config_data is localhost

Check catalog/search/engine and the host rows, run indexer:reindex

Two shops placed an order with the same increment id

Both sides took orders

Stop one side; see "Two writers"