Introduction: why we are writing this now
The end of standard support for Amazon Linux 2 (June 30, 2026) is right around the corner. The longer a WordPress environment has been running in production, the more likely it is that its PHP, MySQL, and Apache versions have been left untouched.
This article records the full set of work involved in migrating one production server (hosting 14 sites and 28 WordPress installs) from Amazon Linux 2 to 2023, PHP 5.6 to 8.5, MySQL 5.7 to MariaDB 10.11, and Apache to nginx. We leaned heavily on an AI assistant, and the hands-on work took roughly one day. That said, we are sharing everything, including the parts riddled with landmines that could not simply be left to the AI.
If you are in the same situation, we hope this helps you decide whether to (1) migrate on your own, (2) outsource the migration, or (3) step away from server management altogether.
1. Before and after: stack comparison
The old server was a 2019 build that had been kept alive ever since, running 26 WordPress databases and 28 installs on PHP 5.6 and MySQL 5.7.
| Item | Old server (AL2) | New server (AL2023) |
|---|---|---|
| OS | Amazon Linux 2 (kernel 4.14, 2019 build) | Amazon Linux 2023 (kernel 6.1) |
| Web | Apache 2.4 + mod_pagespeed | nginx 1.28 |
| PHP | 5.6.40 | 8.5.4 |
| DB | MySQL 5.7.28 | MariaDB 10.11.15 |
| Storage | 100GB gp2 (78GB used) | 30GB gp3, encrypted (24GB used) |
| WordPress | 28 installs / 26 DBs / mix of core 3.x to 6.x | All updated to WP 6.9.4 |
Besides WordPress, the server carried legacy assets such as custom PHP code, Welcart EC, BuddyPress, bbpress, and the Luxeritas theme, so we knew going in that the PHP version jump would be the hardest part.
2. Migration strategy: running in parallel
We chose a "parallel-run" approach: leave the old server as is, build a new AL2023 instance alongside it, verify, then switch DNS. An in-place upgrade to Amazon Linux 2023 isn't possible, and since this involved not just the OS but a four-step PHP major version jump, a setup that let us verify while production kept running was essential.
# create AWS resources (with a named profile) aws ec2 create-security-group --group-name <prefix>-al2023 ... aws iam create-role --role-name <prefix>-al2023-role ... aws ec2 run-instances --image-id ami-XXXXXXXX --instance-type t3.medium ... aws ec2 allocate-address --domain vpc ...
The configuration we chose was t3.medium (with an RI), 30GB encrypted gp3, and an IAM Role with S3 write permissions. How we got from 100GB on the old server down to 30GB is covered in the next section.
3. Data migration: shrinking 63GB to 23GB with rsync
The old server's public directory was 63GB, but by excluding the clearly unnecessary items below we brought it down to 23GB, which meant the new server could run on a 30GB gp3 volume.
- Assorted backups (
ai1wm-backups/,backwpup-*/,*.wpress) - Mail directories (handled separately)
- Old .exe / .db files under an unused
dl/directory (about 30GB) - The contents of subdomains that had been dormant for a long time
The transfer ran over private IPs inside the VPC and finished in about 5 minutes.
rsync -aHA --numeric-ids \ --exclude="ai1wm-backups/" \ --exclude="backwpup-*/" \ --exclude="*.wpress" \ --exclude="/<domain>/public_html/dl/" \ --exclude="/<old-subdomain>/" \ ec2-user@10.0.1.176:/srv/sites/ /srv/sites/
For MySQL, we dumped all 26 databases in one go with mysqldump --add-drop-database --routines --triggers --events and imported them into MariaDB on the new server. MySQL 5.7 → MariaDB 10.11 is mostly straightforward, but DEFINER clauses and the change in default charset between utf8mb3/utf8mb4 need individual attention.
4. PHP 5.6 → 8.5 compatibility: every hellish pattern
With a four-step major version jump (5.6 → 7.0 → 7.4 → 8.0 → 8.5), we ran into a huge number of incompatibilities. WordPress core could be updated to 6.9.4 in one pass with wp-cli core update, but the incompatibilities in themes and plugins that hadn't been updated in years meant one manual fix after another.
| Pattern | PHP behavior | Fix | Scope |
|---|---|---|---|
| $str{0} (string offset with curly braces) | Deprecated in PHP 7.4 / removed in 8.0 | Replace with $str[0] | Dozens of files |
| =& new ClassName() (assign by reference) | Deprecated in PHP 5.3 / removed in 7.0 | Change to = new ClassName() | 52 files |
| create_function('', 'code') | Deprecated in PHP 7.2 / removed in 8.0 | Convert to a closure: function() { code } | 253+ files |
| Signature mismatch in Walker subclasses | Strict in PHP 8.0 | Add default values to make it compatible with the parent | Many |
| Non-static methods called statically | Fatal in PHP 8.0 | Change to static function | Per plugin |
| Smart quotes (' ' " " ) used in string literals | Fatal in PHP 8.0 | Replace with ASCII quotes (across the whole theme) | Entire theme |
| Collision with class Match (reserved word in PHP 8) | Became a reserved word in PHP 8.0 | Rename to something like class SR_Match | 1 plugin |
| ksort($wp_filter[$tag]) (WP_Hook object) | TypeError in PHP 8.0 | Rewrite as ksort($wp_filter[$tag]->callbacks) | bbpress / BuddyPress |
| break 2 (wrong loop depth) | Fatal in PHP 8.0 | Change to break; | 1 plugin |
| Property access on null | Warning → fatal in PHP 8.0 | Add null checks | Many themes |
| Dynamic properties deprecated | Deprecated in PHP 8.2 | Add the #[\AllowDynamicProperties] attribute | Core & many others |
Case ①: BuddyPress and the $wp_admin_nav array problem
Up through PHP 7, pushing onto an uninitialized variable as if it were an array worked without error, but in PHP 8 the type mismatch throws a TypeError.
// BEFORE — PHP 7 carries on even when array-pushing onto an empty string ""
$wp_admin_nav[] = array('parent' => ...);
// AFTER — PHP 8 throws a TypeError, explicit initialization is required
if (!is_array($wp_admin_nav)) $wp_admin_nav = array();
$wp_admin_nav[] = array('parent' => ...);Case ②: removing create_function() from 253 files
The most frequent incompatibility on this project was replacing create_function(), which was removed in PHP 8.0. We wrote sed/perl patterns that parse the arguments and replace each call with a closure, then followed a mechanical replace → verify routine.
Case ③: WordPress 3.x sites won't boot because of mysql_*
Some sites had been left on WP 3.x, and since the mysql_* functions don't exist in PHP 8, WordPress itself won't even start. You must first bring core up to date with wp-cli core download --force and only then work on getting everything consistent.
PHP 8 is strict, period. Things that were warnings in PHP 7 have been promoted across the board to fatal errors. Many old WP plugins haven't been updated by their authors in ages, so the work comes down to deciding, one by one, among three options: patch it yourself / replace it with another plugin / just disable it.
5. nginx vhost design: converting from Apache mod_rewrite
The old Apache setup used DocumentRoot /wp plus a fallback rewrite to route requests to sub-WordPress installs such as /public_html/cart. Reproducing that exactly in nginx is difficult, so we switched to leveraging the existing symlink structure on the filesystem.
# domain A (DocumentRoot is /wp, sub-WP installs reached via symlinks)
server {
server_name <domain-a.example> www.<domain-a.example> pro.<domain-a.example>;
root /srv/sites/<domain-a.example>/public_html/wp;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php-fpm/www.sock;
# ... fastcgi_params
}
}Because the parent domain and subdomains shared web fonts, we add a CORS header for fonts only.
location ~* \.(woff2?|ttf|eot|otf)$ {
add_header Access-Control-Allow-Origin "*";
}6. Minimizing EBS and designing the ops foundation: AMI backups + AWS cost estimate
Our priority in this migration was to keep EBS as small as possible and minimize monthly running costs. From 78GB in use on the old server, we got the new server down to 24GB. By leaving backups to AWS AMI snapshots and immediately offloading access logs and DB dumps to S3, we broke away from a setup where data keeps piling up on EBS.
Design principles
- Consolidate backups on AMI snapshots: remove every WordPress backup plugin (All-in-One WP Migration, BackWPup, UpdraftPlus, etc.) and switch to capturing the whole OS with AWS AMIs.
- Offload access logs to S3 immediately: copy them to S3 within logrotate and keep only 7 days locally on EBS.
- Daily DB dumps to S3: mysqldump → S3, with minimal local retention.
- Clear out years of accumulated backups: delete every legacy
.wpress/.tar/.htaccess_yyyymmddfile (see Section 8).
logrotate → S3
/var/log/nginx/sites/*.log {
daily
rotate 7
lastaction
DATE_DIR=$(date -d yesterday +%Y/%m/%d)
for f in /var/log/nginx/sites/*.log.1.gz; do
aws s3 cp "$f" \
"s3://<bucket>/logs/nginx/sites/$DATE_DIR/$(basename $f .log.1.gz).log.gz"
done
endscript
}An S3 lifecycle rule expires everything under the logs/ prefix after 90 days, keeping storage costs capped as well.
DB backups → S3 (daily at 5:00)
# /etc/cron.d/db-backup 0 5 * * * root /usr/local/bin/db-backup.sh
Even uploading all 26 databases every day comes to only about 35MB in total. Combine it with lifecycle transitions to S3 Glacier Instant Retrieval or IA and long-term storage costs drop even further.
cronie (the cron daemon) by default. We deliberately installed cronie for this setup, but rewriting these as systemd timers is more in line with the AL2023 way of doing things.Why consolidate on AMI backups
Plugin-based backups are convenient, but they pile up compressed archives (.wpress / .zip / .tar) under wp-content indefinitely, forcing EBS to carry capacity it doesn't really need. This is especially serious on servers hosting multiple sites on a single machine; on this project, leftovers from backup plugins alone accounted for tens of gigabytes.
On the new server, backups are consolidated on AWS AMI snapshots (billed incrementally). Every WordPress backup plugin was removed, and recovery is designed around rolling back the entire instance.
AWS cost estimate: keeping 10 generations of AMI snapshots
Assuming we keep 10 generations of AMI snapshots, here is a comparison of EBS-related costs for the old and new setups (calculated with typical Tokyo region pricing).
| Item | Old server (AL2) | New server (AL2023) | Difference |
|---|---|---|---|
| EBS volume ($0.12/GB gp2 / $0.096/GB gp3) | 100GB gp2 = $12.00 / month | 30GB gp3 = $2.88 / month | −$9.12 |
| AMI snapshots × 10 generations ($0.05/GB, billed incrementally. Estimated at ~1.2× the initial size) | 78GB × 1.2 ≈ 94GB = $4.68 / month | 24GB × 1.2 ≈ 29GB = $1.44 / month | −$3.24 |
| Total | ~$16.68 / month | ~$4.32 / month | −$12.36 / month (~74% less) |
That is a saving of roughly $12 / ¥1,800 per month (about ¥22,000 a year at ¥150/USD). The amount itself isn't large, but it adds up when you run multiple servers, and you also get an operational benefit: the smaller the EBS volume, the faster AMI creation and restores become.
- Calculated with typical Tokyo region (ap-northeast-1) pricing. Actual prices vary by region and over time.
- EBS snapshots are billed incrementally. The total for 10 generations is approximated as "initial full size × ~1.2" (assuming a limited rate of data change, since logs and DB dumps are sent to S3). Real-world figures vary with the type of business and update frequency.
- gp3's baseline of 3,000 IOPS / 125 MB/s is included at no extra charge, so no additional IOPS charges are added.
- The EC2 instance itself, data transfer, and S3 storage are calculated separately.
7. Security hardening
Years of operation had opened up more and more holes on the old server, so we used the migration to tighten everything up in one go. Before the audit, 12 world-writable directories and 404 world-writable files remained; all of them have been fixed.
| Category | Measure |
|---|---|
| Sensitive files | Return 403 from nginx for wp-config.php, xmlrpc.php, install.php, readme.html |
| Block PHP execution via uploads | location ~* /wp-content/uploads/.*\.(php|phtml)$ { deny all; } |
| User enumeration protection | 403 for ?author=N, 403 for /wp-json/wp/v2/users |
| HTTP security headers | X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff, Referrer-Policy, Permissions-Policy, HSTS |
| Hide version information | server_tokens off, expose_php = Off |
| Consistent file permissions | Directories 755 / files 644 / wp-config.php 640 |
| Disable file editing from WP admin | Added define('DISALLOW_FILE_EDIT', true) to all 26 wp-config files |
8. Cleanup: 690MB removed
While we were at it, we cleared out years of accumulated cruft.
| Category | Removed |
|---|---|
| Unused plugins (accumulated from the old server era) | 98 |
| Unused themes (old twentyXX themes, etc.) | 86 |
| Backup plugins (All-in-One WP Migration, BackWPup, UpdraftPlus) | 14 |
| Backup files (.wpress, .tar, .htaccess_2019xxxx, etc.) | About 400 files |
| Total reduction | About 690MB |
9. Outbound mail: Postfix + SES SMTP relay
The old server sent mail directly through Postfix (no relayhost configured). On the new server we keep Postfix but point its SMTP relay at AWS SES.
[26 WordPress sites] → PHP mail() → Postfix → SES SMTP → Recipient
- No need to install the WP Mail SMTP plugin on 26 sites
- No changes needed to existing Contact Form 7 / mw-wp-form setups
- Better deliverability with automatic SPF / DKIM signing
- Low cost at $0.10 per 1,000 emails
10. Fixes during client verification
During the verification period, we received many requests to fix broken layouts and small UI details. Typical ones included:
- Replacing or removing defunct third-party widgets (
ws-fe.amazon-adsystem.com, Pocket, etc.) - In WordPress 6.7+,
sizes="auto"caused thumbnails to render unexpectedly huge → disabled via an mu-plugin - Rebuilding the theme's post list layout (image left / text right) with CSS Grid
- Fixing places where the WordPress version leaked via
<meta name="generator"> - Making a custom JS
null.iderror null-safe
11. Remaining tasks
- Move to ALB + ACM (production HTTPS): switch over once the client signs off on verification
- DNS cutover (old → new): same as above
- DNS settings for AWS SES such as DKIM: waiting on the client to update their DNS
- Restrict source IPs allowed for SSH/HTTPS in the Security Group: at production release
12. Effort comparison: how to think about the cost
With the help of an AI assistant, we completed the migration of 28 sites in about one day of hands-on work (8 to 10 hours). Doing the same volume without AI, the PHP 8 compatibility fixes alone could easily have taken several days to a week.
| Option | Upfront cost | Monthly running cost | Operational effort |
|---|---|---|---|
| Migrate to AL2023 yourself | Several days to several weeks of effort (depending on expertise) | EC2 / EBS / backup fees + in-house operating costs | Patching / monitoring / incident response all in-house |
| Have MOOBON handle the AL2023 migration | From ¥100,000 per site as a guideline (quoted separately depending on scale) | EC2 / EBS / backup fees | Operations stay in-house (a maintenance contract is available as a separate option) |
| Move to Kinsta (managed WP) | Migration: free migration by Kinsta or support from MOOBON | From $35/month (depending on plan) | Patching, monitoring, backups, and CDN all handled by Kinsta |
Using the Amazon Linux 2 EOL as the moment to "get out of server management altogether" is a rational choice, especially for companies that run WordPress as one part of their business. As Japan's first official Kinsta partner, MOOBON provides one-stop support from migration through ongoing operations.
13. Advice for anyone about to migrate
- Estimate the PHP 8 compatibility of old WordPress installs and plugins up front with static analysis. Run Rector and PHPCS with the PHPCompatibility ruleset in CI and you will know the scope of impact on day one.
- Settle your rsync exclusions before you migrate. Having a transfer stop midway because EBS filled up is a pain. We got from 63GB down to 23GB because we took the time to design the exclusion strategy.
- Handle environments with many WP installs using automation scripts that drive WP-CLI. Manually editing 28
wp-config.phpfiles is a breeding ground for mistakes. - Use an SES relay for outbound mail from the start. Building it in when you set up the new server is far easier than retrofitting the Postfix configuration later.
- Set aside a separate period for client verification. Even if everything is technically near-perfect, you will always get feedback on small visual details.
- Seriously consider not migrating at all and escaping to managed hosting (Kinsta, etc.). It is worth comparing the cost of tackling each of 28 sites' individual quirks (plugin compatibility, SSL, mail, cron) one by one against a monthly fee plus migration costs.
