Migrating one old WordPress site can be unpredictable. Migrating four of them simultaneously—from an obsolete operating system, an old PHP runtime, aging themes, and years of accumulated plugins—requires a process designed around containment rather than optimism.
The goal of this migration was not simply to copy files onto a new server. It was to establish a clean, verifiable baseline:
- current WordPress core;
- PHP 8.4;
- Nginx and MariaDB on a modern Debian system;
- Cloudflare-proxied public HTTPS;
- the smallest practical active-plugin set;
- preserved historical data and extensions;
- reversible changes at every important stage.
This article reconstructs the procedure, including the failures that influenced the final design.
All domains, addresses, database names, credentials, and private paths have been anonymized.
1. The starting point
The source environment had several characteristics that made an in-place upgrade risky:
- Ubuntu 16.04;
- PHP 7.1;
- an old Nginx release;
- multiple WordPress installations;
- legacy plugin versions from several different eras;
- commercial plugins and themes that could not be updated through WordPress.org;
- caching drop-ins and must-use plugins;
- configuration code dependent on
$_SERVER['HTTP_HOST']; - unknown PHP 8.4 compatibility across thousands of PHP files.
The destination was deliberately small:
- one shared virtual CPU;
- approximately 1 GB RAM;
- approximately 10 GB storage;
- IPv6-only public connectivity;
- Debian 13;
- PHP 8.4-FPM;
- Nginx;
- MariaDB;
- Cloudflare in front of the origin.
A machine this small can serve modest WordPress sites, but it leaves little room for careless duplication, uncontrolled backups, or activating many resource-heavy plugins at once.
2. The central migration principle
The safest decision was to separate data from executable code.
The following items were treated as data that needed to survive:
- database contents;
- media uploads;
- selected configuration values;
- active theme files;
- original plugin activation state;
- legacy commercial packages for reference or recovery.
The following were treated as replaceable executable code:
- WordPress core;
- WordPress.org plugins;
- caching drop-ins;
- old plugin binaries;
- obsolete bundled libraries.
This distinction made it possible to preserve the sites without immediately trusting every historical PHP file.
3. Build a complete inventory before changing anything
The initial inventory recorded:
- WordPress roots;
- database names and table prefixes;
- canonical
homeandsiteurlvalues; - active theme and child-theme relationships;
- active plugins;
- must-use plugins;
- drop-ins such as
advanced-cache.php; - PHP-FPM sockets;
- enabled Nginx configurations;
- service state;
- available storage.
A simplified inventory structure looked like this:
SITES=(
/var/www/site-a
/var/www/site-b
/var/www/site-c
/var/www/site-d
)
for site in "${SITES[@]}"; do
echo "=== $site ==="
wp --allow-root --path="$site" \
option get home \
--skip-plugins \
--skip-themes
wp --allow-root --path="$site" \
theme list \
--status=active
wp --allow-root --path="$site" \
plugin list \
--status=active
done
The original active-plugin state was stored separately before any database changes. This later made it possible to distinguish:
- originally active plugins;
- originally inactive plugins;
- newly installed clean replacements;
- plugins intentionally left isolated.
4. Disable and isolate legacy executable extensions
The old plugins were not deleted. They were moved outside the web roots into a root-only preservation directory.
Conceptually:
/root/migration-state/
├── active-plugins-before-php84.tsv
└── legacy-extensions/
├── site-a/
│ ├── plugins/
│ ├── mu-plugins/
│ └── drop-ins/
├── site-b/
├── site-c/
└── site-d/
The database activation lists were cleared before the sites were bootstrapped under PHP 8.4.
This avoided several classes of failure:
- fatal errors from PHP-incompatible plugins;
- automatic plugin database migrations;
- old caching code running against the new Nginx configuration;
- must-use plugins loading before ordinary plugins could be disabled;
- compromised or unverifiable historical files becoming public again.
Preserving the code outside the web root retained forensic and recovery value without allowing it to execute.
5. Replace WordPress core with a verified clean copy
Instead of incrementally updating an unknown core tree, one verified current WordPress package was downloaded and reused for all four installations.
After installation, each site was checked against official checksums:
wp --allow-root --path="/var/www/site-a" \
core verify-checksums
The databases were then checked and upgraded:
wp --allow-root --path="/var/www/site-a" \
db check \
--skip-plugins \
--skip-themes
wp --allow-root --path="/var/www/site-a" \
core update-db \
--skip-plugins \
--skip-themes
In this migration, the databases successfully moved from substantially older WordPress database versions to the database version expected by WordPress 7.0.3.
The important point was sequencing:
- disable executable extensions;
- install clean core;
- verify core checksums;
- check the database;
- run the core database upgrade;
- test the active theme without plugins.
6. Remove request-dependent canonical URL configuration
Several historical wp-config.php files generated home and siteurl from:
$_SERVER['HTTP_HOST']
That produced WP-CLI warnings because command-line requests do not have a normal HTTP host:
PHP Warning: Undefined array key "HTTP_HOST"
It was also undesirable from a security and operational perspective. Canonical URLs should not depend blindly on an incoming Host header.
The obsolete assignments were removed, while canonical values remained in the WordPress database.
After correction:
- PHP syntax checks passed;
- WP-CLI stopped requiring an HTTP request context;
- every site returned its intended canonical HTTPS URL;
- no database password or secret was printed.
7. Test themes independently from plugins
Before exposing the sites publicly, every active theme was scanned with PHP 8.4:
find "/var/www/site-a/wp-content/themes/active-theme" \
-type f \
-name '*.php' \
-print0 |
while IFS= read -r -d '' file; do
php -l "$file" >/dev/null
done
The active themes passed syntax validation and WordPress bootstrap tests.
This did not prove that every visual feature worked, but it established that:
- PHP could parse every theme file;
- WordPress could bootstrap the theme;
- the homepage could render without active plugins.
That clean theme-only baseline was essential. Without it, a later failure could have come from core, the theme, a plugin, Nginx, PHP-FPM, or the database.
8. Use local-only Nginx staging before public cutover
The migrated sites were first exposed only on:
127.0.0.1:8080
A simplified staging virtual host looked like this:
server {
listen 127.0.0.1:8080;
server_name site-a.example;
root /var/www/site-a;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
try_files $uri =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME
$document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
}
}
This allowed the full Nginx-to-PHP-FPM-to-WordPress path to be tested without publishing the migration.
Example local request:
curl \ --resolve site-a.example:8080:127.0.0.1 \ -I \ http://site-a.example:8080/
Each site was tested for:
- homepage HTTP 200;
- login-page HTTP 200;
- correct content type;
- absence of new Nginx errors.
A listener-check failure
The first staging attempt passed nginx -t but reported that Nginx was not listening on 127.0.0.1:8080.
The script rolled back the new files.
The corrected attempt explicitly waited for the listener after reloading Nginx:
nginx -t
systemctl reload nginx
for attempt in {1..20}; do
if ss -ltn | grep -q '127.0.0.1:8080'; then
break
fi
sleep 0.25
done
The corrected listener appeared and all four local sites returned HTTP 200.
The lesson was simple: a valid configuration file does not prove that the intended listener is active.
9. Treat plugin recovery as a staged compatibility exercise
A syntax scan of the preserved plugins found many PHP 8.4 failures.
Examples included:
- old Jetpack libraries using syntax removed from modern PHP;
- outdated UpdraftPlus vendor libraries;
- an old Bridge Core release;
- a dependency using
matchas an identifier aftermatchbecame a PHP keyword; - obsolete nested ternary expressions.
A successful lint scan also did not guarantee runtime compatibility, but a failed lint scan was enough to reject a package.
The legacy files were never patched in place. Clean packages were downloaded instead.
For WordPress.org plugins:
wp --allow-root --path="/var/www/site-a" \
plugin install classic-editor
wp --allow-root --path="/var/www/site-a" \
plugin verify-checksums classic-editor
The clean packages were:
- installed inactive;
- verified against WordPress.org checksums;
- scanned with PHP 8.4;
- tested without activation;
- activated individually only when necessary.
After every activation:
- WordPress bootstrap was tested;
- the homepage was requested;
- the login page was requested;
- active state was verified.
10. Keep high-risk plugins inactive until justified
Several plugins were intentionally deferred:
- Jetpack;
- backup plugins;
- database optimization plugins;
- page builders;
- importers;
- caching plugins;
- multilingual plugins;
- commercial plugins without current packages.
This reduced both debugging complexity and runtime cost.
A minimal site that works is a more useful recovery baseline than a feature-complete site whose failures cannot be attributed.
11. Update commercial theme packages separately
One site depended on a commercial multipurpose theme and companion plugins.
The newly downloaded licensed package contained:
- Bridge theme 30.8.9.1;
- Bridge Core 3.3.4.9;
- WPBakery;
- Slider Revolution;
- LayerSlider;
- several optional Qode extensions.
The outer archive and nested archives were inspected before installation.
The required theme and core plugin were extracted into staging and scanned:
Bridge theme: 388 PHP files; 0 syntax failures Bridge Core: 736 PHP files; 0 syntax failures
The upgrade sequence was:
- create a database checkpoint;
- preserve the previous theme files;
- install the new theme;
- test the theme with its core plugin inactive;
- activate Bridge Core;
- test again;
- activate Elementor;
- flush rewrite rules;
- flush Elementor-generated CSS;
- verify final versions and page responses.
A PHP 8.4 deprecation notice appeared in Elementor concerning an implicitly nullable parameter. It was non-fatal, but it was recorded rather than ignored.
WPML remained absent and inactive during this stage.
12. Publish through Cloudflare with strict origin TLS
The server had public IPv6 but no usable public IPv4. Cloudflare proxied AAAA records were therefore used to reach the origin.
Before cutover:
- the origin returned the sites correctly on the local listener;
- public Cloudflare requests returned origin-connection failures;
- Nginx listened publicly only on port 80;
- no public HTTPS listener existed.
Cloudflare Origin CA certificates were created separately for the two domain families. Each certificate covered:
*.example-domain.tld example-domain.tld
Certificate and key pairs were:
- stored outside the web roots;
- owned by root;
- given restrictive permissions;
- checked for matching public keys;
- inspected for correct subject alternative names;
- never printed into logs or documentation.
Cloudflare SSL/TLS mode was set to:
Full (strict)
A simplified public TLS virtual host looked like this:
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name site-a.example;
ssl_certificate
/etc/ssl/cloudflare/example-origin.pem;
ssl_certificate_key
/etc/ssl/cloudflare/example-origin.key;
root /var/www/site-a;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME
$document_root$fastcgi_script_name;
fastcgi_param HTTPS on;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
}
}
Duplicate try_files
The first public configuration failed:
"try_files" directive is duplicate
The shared PHP snippet already contained a try_files directive, while the new location added another.
The correction was to choose one coherent PHP-routing definition instead of combining two partially overlapping snippets.
Port 443 not detected
A later configuration passed nginx -t but the validation script checked port 443 too quickly and rolled back.
The final cutover:
- validated configuration syntax;
- reloaded Nginx;
- waited for the listener;
- confirmed both IPv4 and IPv6 port 443 sockets;
- tested every hostname directly through Nginx;
- tested the same hostnames through Cloudflare.
Final public results:
- subdomains returned HTTP 200;
- apex domains returned intentional HTTP 301 redirects;
- Nginx, PHP-FPM, and MariaDB remained active;
- the local port 8080 fallback remained available.
13. Replace Jetpack-only logo functionality with native WordPress support
Some sites had relied on Jetpack’s Site Logo feature.
Activating all of Jetpack merely to render a logo would have added unnecessary code and requests. The themes were therefore adapted to native WordPress custom-logo support.
Theme setup:
add_theme_support(
'custom-logo',
array(
'height' => 200,
'width' => 600,
'flex-height' => true,
'flex-width' => true,
)
);
Header rendering:
if (
function_exists( 'the_custom_logo' )
&& has_custom_logo()
) {
the_custom_logo();
}
One migrated database still stored a Jetpack-era logo structure rather than the integer attachment ID expected by native WordPress.
The preserved structure referenced an attachment ID. After confirming that the media attachment existed, the native theme modification was set to that integer.
The final rendered markup was similar to:
<a
href="https://site.example/"
class="custom-logo-link"
rel="home"
>
<img
width="75"
height="75"
src="https://site.example/wp-content/uploads/logo.png"
class="custom-logo"
alt="Site name"
/>
</a>
Jetpack remained inactive.
14. Debug WordPress dashboard installation permissions precisely
Later, Classic Editor failed to install from the dashboard:
Installation failed: Could not create directory. wp-content/upgrade/classic-editor.1.7.0
The diagnostic compared the PHP-FPM identity with every relevant directory:
stat -c \ 'mode=%a owner=%U group=%G path=%n' \ /var/www/site-main/wp-content/upgrade runuser -u www-data -- \ test -w /var/www/site-main/wp-content/upgrade
Everything relevant was owned by www-data:www-data except:
wp-content/upgrade mode=755 owner=root group=root www-data write access: NO
The correction was intentionally narrow:
chown www-data:www-data \ /var/www/site-main/wp-content/upgrade chmod 0755 \ /var/www/site-main/wp-content/upgrade
A write test as www-data passed afterward.
Classic Editor 1.7.0 could then be:
- installed;
- deleted;
- reinstalled;
- activated
through the WordPress dashboard.
This demonstrated why chmod 777 is almost never the correct response to a WordPress permission error. The problem was ownership of one specific temporary directory.
15. Automatically summarize homepage articles
The Penscratch theme displayed complete articles on the homepage unless the author manually inserted:
<!--more-->
The desired behavior was:
- automatically show a short homepage teaser;
- preserve manual More tags as author-controlled overrides;
- preserve old articles containing manual markers;
- keep single articles complete;
- retain lists, code blocks, quotes, and other formatting;
- avoid rewriting database content.
First attempt: wp_trim_words()
The initial automatic implementation used:
$summary = wp_trim_words(
$source,
80,
'…'
);
This produced the correct length, but WordPress intentionally removes HTML from wp_trim_words() output.
Lists, code, and other structures became plain text.
Second attempt: cut at a complete block
The next version located the first block ending after approximately 80 words and inserted a temporary More marker there.
This preserved:
- paragraphs;
- lists;
- code blocks;
- blockquotes;
- tables;
- figures.
However, changing the target from 80 to 60 appeared to do nothing. Both word positions were inside the same block, so both selected the same closing tag.
Final approach: exact visible-word position
The final implementation counts visible text while ignoring:
- HTML tags;
- HTML comments;
- shortcode markup.
The tokenizer is:
preg_match_all(
'/<!--.*?-->|<[^>]*>|\[[^\]]*\]|[^<\[]+/s',
(string) $raw_content,
$content_tokens,
PREG_OFFSET_CAPTURE
);
Words are counted only inside visible-text tokens:
preg_match_all(
'/\S+/u',
$token,
$word_matches,
PREG_OFFSET_CAPTURE
);
At the 60th word, the byte position is recorded. The current post is then cloned:
$virtual_post = get_post(); $virtual_post = clone $virtual_post;
A temporary ellipsis and More marker are inserted into the clone:
$virtual_post->post_content =
substr(
$raw_content,
0,
$cut_position
)
. '…'
. "\n<!--more-->\n"
. substr(
$raw_content,
$cut_position
);
The clone is rendered through WordPress:
$rendered_content = get_the_content(
__(
'Continue reading <span class="meta-nav">→</span>',
'penscratch'
),
false,
$virtual_post
);
$rendered_content = apply_filters(
'the_content',
$rendered_content
);
The saved post is never changed.
Manual More tags still win
Before automatic processing, the raw content is checked:
$has_more_tag = (bool) preg_match(
'/<!--more(?:.*?)?-->/is',
(string) $raw_content
);
The template decision becomes:
Search page
-> normal WordPress excerpt
Homepage with manual More tag
-> native WordPress content teaser
-> author-selected position
Homepage without manual More tag
-> automatic 60-word formatted teaser
-> ellipsis
-> Continue reading link
Single article
-> complete content
16. Why complete-file staging was safer than repeated patching
Two automatic patch attempts failed safely.
One generated invalid PHP containing an unexpected backslash:
PHP Parse error: syntax error, unexpected token "\"
The rollback restored the previous valid file.
A second patch relied on matching an explanatory comment exactly and stopped with:
ERROR: Expected old explanatory comment was not found.
It failed before deployment, so the live theme was unchanged.
The final strategy stopped trying to rewrite a small section of an evolving file. Instead, it:
- backed up the complete live file;
- wrote a complete candidate outside the active theme;
- ran PHP lint against the candidate;
- deployed only if lint succeeded;
- linted the deployed file;
- bootstrapped WordPress;
- tested the homepage, pagination, and a single post;
- retained rollback until every test passed.
Conceptually:
cp content.php content.php.backup write_complete_candidate php -l content.php.candidate cp content.php.candidate content.php php -l content.php wp eval 'echo "WordPress bootstrap: OK\n";' test_homepage test_pagination test_single_article
This pattern is useful beyond WordPress. When a configuration or source file has already passed through several iterations, replacing a verified complete candidate can be safer than increasingly fragile regular-expression surgery.
17. Final validation
At the final stopping point:
- all four sites were publicly available through Cloudflare;
- Nginx configuration passed validation;
- ports 80 and 443 were listening;
- PHP 8.4-FPM was active;
- MariaDB was active;
- WordPress core verified successfully;
- database upgrades completed;
- active themes bootstrapped;
- selected plugins were installed from clean packages;
- high-risk plugins remained inactive;
- the commercial theme and required companion plugin were current;
- WPML remained deferred;
- Jetpack remained inactive;
- native logos rendered correctly;
- Classic Editor installed and activated correctly;
- automatic 60-word homepage teasers worked;
- manual More tags remained authoritative;
- homepage, paginated homepage, login, and single-post tests returned HTTP 200;
- no post content was rewritten by the teaser system.
18. Lessons from the migration
Make the clean baseline boring
A clean WordPress core, one active theme, and no plugins may look incomplete, but it gives every later failure a smaller cause set.
Preserve old code without executing it
Deleting legacy code immediately destroys recovery information. Leaving it in the web root risks execution. Root-only isolation provides a useful middle ground.
Syntax checks are necessary but insufficient
A PHP file can lint successfully and still fail because of:
- removed runtime behavior;
- deprecated APIs;
- incompatible database assumptions;
- JavaScript errors;
- plugin interactions.
Linting is the first gate, not the last.
Activate one component at a time
Batch activation makes failures ambiguous. Sequential activation makes rollback obvious.
Never confuse configuration validity with runtime state
nginx -t proves syntax, not listener availability, routing correctness, certificate reachability, or application health.
Avoid broad permission changes
When one directory is unwritable, inspect that directory and its parent chain. Do not recursively make the entire WordPress tree web-writable.
Treat CDN and origin as separate systems
A successful direct-origin test and a successful Cloudflare test prove different things. Both are necessary.
Prefer native functionality over large dependency bundles
A few lines of native custom-logo support replaced the need to activate Jetpack solely for logo rendering.
Stage generated source before deployment
If code is produced programmatically, write it outside the active application, lint it, and only then deploy it.
Record failed attempts
The failed listener checks, duplicate Nginx directive, cache warning, invalid WP-CLI field, PHP parse error, and fragile comment match all contributed to a safer final procedure.
19. Remaining risks and future improvements
The system is operational, but several maintenance concerns remain.
Direct parent-theme modifications
The customized templates can be overwritten by a theme update.
A future iteration should move customizations into:
- a child theme; or
- a narrowly scoped custom plugin plus minimal template overrides.
Exact token counting
The teaser algorithm counts whitespace-separated visible tokens. Edge cases include:
- Chinese or Japanese text without spaces;
- unusual HTML entities;
- deeply nested Gutenberg structures;
- dynamic blocks;
- complex shortcodes;
- very large code blocks.
These are known limitations rather than confirmed failures.
Plugin write permissions
Allowing PHP-FPM to modify plugin code makes dashboard installation possible, but it also increases the consequences of a compromised WordPress administrator account.
For stricter production systems, plugin deployment through a controlled command-line or CI process is preferable.
Small-server resource limits
A 1 GB server requires continued discipline:
- avoid running multiple backup jobs simultaneously;
- monitor disk usage;
- avoid unnecessary page builders and optimization plugins;
- limit PHP-FPM workers appropriately;
- keep database and log growth under observation;
- retain off-server backups.
Conclusion
The most important result was not merely that four WordPress sites came back online. It was that they emerged with a comprehensible operational state.
The final platform had:
- a verified core;
- known database versions;
- a modern PHP runtime;
- explicit Nginx routing;
- strict Cloudflare origin TLS;
- a minimal plugin surface;
- current licensed theme components;
- native replacements for unnecessary plugin features;
- documented rollback points;
- automatic formatted homepage teasers;
- a repeatable method for future changes.
Legacy WordPress migrations become dangerous when every historical component is allowed to return at once. They become manageable when code is isolated, state is recorded, changes are staged, and every layer is tested independently.
