Skip to main content

Moving the site to the new AWS tenant

This is the procedure for moving nutritionistpro.com out of the old Axxya AWS tenant and into the nexgen_v3 tenant. The work is tracked in issue 713, and since 2026-10-21 it is also the only way to get the site onto a PHP that has the fix for CVE-2026-109618 and CVE-2026-109619 (issue 746). Read Hosting and deployment first; it is the inventory of what has to come across. Apply PHP security stopgap (October 2026) on the old instance before starting, if that has not been done.

Why move​

  • Every other Axxya workload is in the nexgen_v3 tenant. The website is the last thing keeping the old account alive.
  • The current instance runs a Bitnami image from late 2021: PHP 7.4.24, Apache 2.4.51 with OpenSSL 1.1.1n, and MariaDB 10.3.31. All are past end of life and get no security fixes. See Hosting and deployment.
  • PHP 7.4 received no release for the two critical remote code execution bugs fixed on 2026-10-21, and the Bitnami stack cannot have its PHP replaced in place. The stopgap measures on the old instance are partial; see PHP security stopgap (October 2026).

Why a fresh instance rather than a copy​

The move builds a new Bitnami WordPress Lightsail instance in the nexgen_v3 tenant and migrates the WordPress content into it. It does not copy the existing instance, for three reasons:

  • Lightsail snapshots cannot be shared between AWS accounts. The only cross-account path is to export the snapshot to an EC2 AMI and share that, and an AMI cannot be imported into Lightsail. A "copy" therefore means an EC2 instance in the new account, at EC2 pricing, without Lightsail's static IP, firewall, and automatic snapshots.
  • A copy carries the end-of-life stack across unchanged. Bitnami does not upgrade a stack in place; its upgrade path is a new instance plus a migration. Copying now would mean doing this whole exercise again for the PHP upgrade.
  • A copy keeps every unknown on the old box: the reverted-config trap, stale keys, old certificate configuration. A content migration moves only the WordPress data (database, uploads, plugins, theme). The WPCode snippets, Redirection rules, Yoast, WP Rocket, and Gravity Forms settings all live in the database, so they come across with it.

The snapshot export route is kept in Alternative: export the snapshot to EC2 for the case where the theme or a plugin turns out not to run on PHP 8. Since the October 2026 PHP release it is a last resort only, because it leaves the two unfixed vulnerabilities in place.

Which blueprint to launch​

Launch the new instance from the WordPress blueprint whose provider is Lightsail rather than the one "certified by Bitnami". The reasons:

  • AWS stopped publishing new versions of the Bitnami-packaged blueprints on 2026-05-19 and retires the Bitnami WordPress, WordPress Multisite, LAMP, Nginx, and Node.js blueprints on 2026-11-19. Existing Bitnami instances keep running and can still be restored from snapshots, but the blueprint cannot be chosen for a new instance after that date.
  • A Bitnami instance launched today ships PHP 8.2.x as of its last update in May 2026, which predates the 2026-10-21 security release, and a Bitnami stack cannot have its PHP upgraded in place. It would need replacing again immediately.
  • The Lightsail-packaged blueprint uses the operating system's own packages (apt), so PHP is patched the same way as any Debian or Ubuntu server, and it enforces IMDSv2 and a restricted port 22 at launch. It pre-installs All-in-One WP Migration along with Jetpack, All in One SEO, WP Mail, W3 Total Cache, and AMP; the content move below replaces the database and the active plugin set, and the leftover inactive plugins are deleted afterward.

The new instance's layout​

The Lightsail-packaged blueprint is a standard Debian layout rather than the /opt/bitnami tree the rest of this section describes. Confirmed on the instance built on 2026-10-21 (blueprint id wordpress_ls_1_0, WordPress 7.1):

  • Operating system Debian 12 (bookworm). PHP 8.2 from Debian's own packages, running as mod_php (libapache2-mod-php8.2 under the prefork MPM; there is no PHP-FPM package installed). PHP configuration under /etc/php/8.2/apache2/ and /etc/php/8.2/cli/. MariaDB 10.11.
  • SSH user admin, not bitnami. The instance's key pair is whichever one was named at creation; Lightsail's key import accepts RSA keys only, and the CLI wants the public key file itself (--public-key-base64 file://...pub), not a base64 string.
  • Web root /var/www/html, owned by admin:www-data with the setgid bit, and wp-config.php one level up at /var/www/wp-config.php (mode 440). phpmyadmin is symlinked into the web root; remove that link before cutover.
  • Apache controlled with sudo systemctl restart apache2, error log at /var/log/apache2/error.log, sites 000-default.conf (port 80) and default-ssl.conf under /etc/apache2/sites-enabled/.
  • The WordPress admin credentials for the user account are in ~/application_credentials, a symlink to /opt/aws/wordpress/credentials.log, which holds the user name and the password on two lines. Do not cat it in a session whose output is kept anywhere; read it once and treat it as spent after the content import replaces the users.
  • WP-CLI is installed at /usr/local/bin/wp; run it as sudo -u www-data wp --path=/var/www/html ....
  • Bundled plugins: Jetpack (active), and inactive Akismet, All-in-One WP Migration 7.112, All in One SEO, AMP, Google Analytics for WordPress, Hello Dolly, Simple Tags, W3 Total Cache, and WP Mail SMTP. The content import replaces them.
  • The firewall at launch opens 80 and 443 to the world and 22 to four Lightsail aliases only (lightsail-connect, lightsail-connect-ipv6, lightsail-setup-ipv4, lightsail-setup-ipv6). Tighten it before anything else, keeping those aliases on 22; the HTTPS wizard needs the two setup aliases.
  • HTTPS is normally set up by the console's Set up your website wizard, which does not fit this site; see The origin certificate.

Where the steps below name a Bitnami path or ctlscript.sh, use the equivalent from this list on the new instance.

The patched PHP gate​

The new instance must run a PHP that contains the fix for CVE-2026-109618 and CVE-2026-109619 before the content is moved and before its firewall admits anything other than Cloudflare and your own address. Run this first, over the console's browser SSH:

head -2 /etc/os-release
php -v | head -1
apache2ctl -M 2>/dev/null | grep -i php
systemctl list-units --type=service | grep -iE "php|apache|maria"
sudo apt update && apt list --upgradable 2>/dev/null | grep -i php
apt changelog php*-common 2>/dev/null | grep -i "CVE-2026-1096" | head -3
cat ~/application_credentials
ls -la /var/www/html | head; ls -la /var/www/wp-config.php
which wp || echo "no WP-CLI"

Distributions sometimes backport fixes without changing the PHP version, so php -v alone can mislead. The gate is either a package changelog naming CVE-2026-109618 or a package version equal to or above the upstream fixed release (8.2.35 for the 8.2 branch), after sudo apt-get upgrade and sudo systemctl restart apache2. On 2026-10-21 the fresh instance shipped PHP 8.2.33 and Debian's oldstable-security suite was already offering 8.2.35-1~deb12u1, the fixed upstream release packaged for bookworm, so the gate passed the same day. Debian's changelog server had not yet published the changelog for it, but the Debian security tracker entry for CVE-2026-109618 listed 8.2.35-1~deb12u1 as fixed for bookworm-security, which is the authoritative confirmation when a changelog is missing. If a future rebuild finds the distribution still waiting, keep the instance's firewall closed to the world, continue the build steps that do not expose it, and do not cut over until the fix lands; the fallback is an Ubuntu 24 base blueprint with Apache, PHP 8.3, MariaDB, and WordPress installed by hand.

The blueprint runs PHP as mod_php (confirmed: php_module under mpm_prefork_module). That is the server API PHP rates as highest risk for these bugs when unpatched, which is the other reason the gate comes before anything else and the firewall is tightened at launch.

After the content is in, wp-admin Tools > Site Health must show no PHP version warning, and the checks in Test the new origin before cutover include a scan of the Apache error log for fatal errors from Divi or any plugin on PHP 8. Divi 4.27 and the main plugins support PHP 8; the smaller widget plugins are the ones to watch.

Site Health reports PHP 8.2.35 as "an older version of PHP, which should be updated". That is WordPress recommending 8.3 or newer, not a security finding: 8.2.35 is the fixed release for the 8.2 branch and nothing newer exists for it. The caveat is that upstream PHP ends security support for 8.2 on 2026-12-31. Debian's security team keeps patching bookworm's php8.2 packages after that (bookworm is supported until mid-2028), so the instance stays patched, but the message stays, and moving to PHP 8.3 or 8.4 would mean a rebuild on a newer image or a third-party package repository. Treat it as an open item, not a blocker.

Before you start​

  • Fill in every TODO (step 1) on Hosting and deployment and Domain and DNS. The inventory is the checklist for the move.
  • Collect the license credentials for Divi (Elegant Themes), WP Rocket, and Gravity Forms. Each plugin re-activates on the new site with its license key; the keys come across in the database but the license servers may count the new domain or server as a new activation.
  • Note the WP Mail SMTP mailer settings and have its credentials available in case they are stored as wp-config.php constants rather than in the database.
  • Confirm you can edit DNS on the nutritionistpro.com zone in Cloudflare, since the A record change is the cutover.
  • Take a manual snapshot of the old instance in the old tenant's Lightsail console and wait for it to complete.
  • Plan a short window. The cutover itself is a DNS edit on a proxied record and takes effect within seconds, but content published on the old site after the export is lost unless it is re-entered, so stop editing the site between the export and the cutover.

Build the new instance​

Done on 2026-10-21

The whole procedure ran on 2026-10-21 and the site has been served from the new instance since about 19:05 UTC that day; the post-cutover checks passed. The notes below record what was found along the way so that the next rebuild is quicker.

nutritionistpro-web exists in the nexgen_v3 tenant (us-east-1a, bundle large_3_0, key pair cawood-mac, tags purpose=nutritionistpro-website and issue=713), created with the AWS CLI rather than the console. Steps 1 to 5 are done (step 3 with the operator's address added to 443 for testing): the static IP nutritionistpro-web-ip is 35.174.58.107, which becomes the Cloudflare A record at cutover; automatic snapshots are on; the firewall is Cloudflare-only; and apt-get upgrade brought PHP to 8.2.35 the same day, verified as apache2handler version=8.2.35 inside a served request. Step 6 is done too: the wp-config.php block was installed and php -l passed, with the image's original file kept as /var/www/wp-config.php.bak. The Cloudflare Origin CA certificate (both names, valid to 2041-10-17) is installed and Apache presents it. The local copies of the pair are in the operator's private keys folder under WordPress_NewAWS_Oct_2026_keys. A kernel update (linux-image-cloud-amd64 6.1.190) is installed but needs a reboot; reboot before cutover. The content was moved the same evening (database without the Redirection log rows, wp-content without the backup folders and caches), the .htaccess was written, and the curl checks below passed for the homepage, login page, pricing and trial pages, sitemap, robots.txt, and REST API; llms.txt is 404 until Generate Now is run. The reboot, the PHP limits, the ServerName fix, the core update to 7.1.3, the salts and WP_CACHE in wp-config.php, and the plugin and theme cleanup were all done that evening, and Site Health then showed PHP 8.2.35 under apache2handler. The wp-admin checks (Site Health, licenses, the WP Mail SMTP test, the WPCode snippets, llms.txt, page comparison, trial form) passed, and the A record was changed at about 19:05 UTC. What remains is on the after-the-move list.

In the nexgen_v3 tenant's Lightsail console, region us-east-1, or with the AWS CLI on the default profile (the commands used on 2026-10-21 are under each step):

  1. Create an instance from the WordPress blueprint with Lightsail as the blueprint provider (see Which blueprint to launch). Pick a bundle at least as large as the current one (see the inventory), since Divi and WP Rocket are memory-hungry. With the CLI, import an RSA public key first, then create the instance; the blueprint id is wordpress_ls_1_0 (the id wordpress is the Bitnami one):

    aws lightsail import-key-pair --key-pair-name cawood-mac --public-key-base64 file://$HOME/.ssh/lightsail-nexgen-v3.pub
    aws lightsail create-instances --instance-names nutritionistpro-web --availability-zone us-east-1a --blueprint-id wordpress_ls_1_0 --bundle-id large_3_0 --key-pair-name cawood-mac --ip-address-type dualstack --tags key=purpose,value=nutritionistpro-website key=issue,value=713
  2. Create a static IP and attach it. Write it down; it becomes the new Cloudflare A record. With the CLI:

    aws lightsail allocate-static-ip --static-ip-name nutritionistpro-web-ip
    aws lightsail attach-static-ip --static-ip-name nutritionistpro-web-ip --instance-name nutritionistpro-web
    aws lightsail get-static-ip --static-ip-name nutritionistpro-web-ip --query 'staticIp.ipAddress' --output text
  3. Set the firewall on the instance's Networking tab: SSH (22) restricted to the office or VPN addresses plus the lightsail-connect alias (the console's browser SSH client), and HTTP (80) and HTTPS (443) restricted to Cloudflare's published IP ranges. The console accepts at most 30 sources per rule, so use the AWS CLI script in PHP security stopgap (October 2026) against the nexgen_v3 tenant; add your own address to the 443 rule while testing. This closes the open hardening item from the old instance. Leave the rules wide open only while a certificate challenge needs them.

  4. Turn on automatic snapshots for the instance: aws lightsail enable-add-on --resource-name nutritionistpro-web --add-on-request addOnType=AutoSnapshot.

  5. Run the PHP gate in The patched PHP gate and patch the instance. Fetch the WordPress admin password from ~/application_credentials; it is the password for the user WordPress admin until the content import replaces the users.

  6. Install the site URL block in /var/www/wp-config.php before the import so the site answers as https://nutritionistpro.com from the start. The Lightsail image ships the same trap as Bitnami: its wp-config.php defines WP_HOME and WP_SITEURL as 'http://' . $_SERVER['HTTP_HOST'] . '/' (plus the WP_CLI block and WP_AUTO_UPDATE_CORE), so the two define lines are replaced and the HTTP_X_FORWARDED_PROTO guard is added, exactly as on WordPress configuration. The file is mode 440, owned by admin:www-data, so edit with sudo, keep a .bak, run php -l on it, and sudo systemctl restart apache2. Two things on this image not to touch: DB_PASSWORD is read at runtime from /opt/aws/wordpress/credentials.log (the last line of that file), so the file must stay in place with its permissions even after the admin account it also names is gone; and the odd-looking ABSPATH define near the end is never reached (wp-load.php in the web root defines it first), so leave it alone.

The origin certificate​

Cloudflare's Full (strict) mode needs a certificate on the new origin that Cloudflare trusts. Two ways:

  • Cloudflare Origin CA certificate (preferred). In Cloudflare, SSL/TLS > Origin Server > Create Certificate, for nutritionistpro.com and *.nutritionistpro.com. On the Lightsail image the SSL virtual host is /etc/apache2/sites-enabled/default-ssl.conf, ssl_module is already loaded, and the vhost ships with Debian's placeholder pair (SSLCertificateFile /etc/ssl/certs/ssl-cert-snakeoil.pem and SSLCertificateKeyFile /etc/ssl/private/ssl-cert-snakeoil.key). Save the Origin CA certificate as /etc/ssl/certs/nutritionistpro-origin.pem and the key as /etc/ssl/private/nutritionistpro-origin.key (owner root, mode 600), point the two directives at them, run sudo apache2ctl configtest, and sudo systemctl reload apache2. The port-80 vhost (000-default.conf) already answers a 301 to https:// unless the request carries X-Forwarded-Proto: https, which is the right behavior behind Cloudflare and needs no change. It is valid for up to fifteen years, needs no renewal job, and works before cutover because no domain validation is involved. It is trusted only by Cloudflare, which is fine since the firewall only admits Cloudflare.
  • Let's Encrypt via the Lightsail wizard (fallback only). The console's Set up your website wizard (Connect tab) issues and renews a Let's Encrypt certificate, but it refuses to proceed until the domain's A and AAAA records resolve to the instance's own addresses, and a proxied Cloudflare record resolves to Cloudflare. Using it means temporarily un-proxying the apex record (grey cloud) after cutover, opening port 80 to the world for the run, then re-proxying and tightening the firewall. During that window the origin IP is public and the WAF is bypassed, so prefer the Origin CA route. The Bitnami bncert-tool the old runbook named does not exist on the Lightsail-packaged blueprint.

Move the content​

The site content is about 2.5 GB (database 1.15 GB, uploads 701 MB, plugins 576 MB, themes 90 MB), which is more than the free All-in-One WP Migration plugin imports, and wp-content on the old instance also holds 2.9 GB of old backups (ai1wm-backups and updraft) that must not be copied. So the move is a database dump plus a tar of wp-content, pushed from the old instance to the new one over SSH. The sizes are on Hosting and deployment.

The database's size is mostly the Redirection plugin's two log tables, wp_redirection_404 (544 MB) and wp_redirection_logs (319 MB). They are logs, not content, so the dump carries their structure and none of their rows: the new site starts with empty logs and the import takes minutes instead of an hour. Nothing is deleted on the old instance, whose snapshot keeps the full tables.

Prepare the push from the old instance​

The old instance's key pair (nprowp1) is not on the Mac and the new instance has no reason to trust the old one, so the transfer runs from the old instance's browser SSH session with a key made for the purpose. Done on 2026-10-21 for steps 1 and 2.

  1. On the old instance (browser SSH, user bitnami), make a key and print its public half:

    ssh-keygen -t ed25519 -N "" -C "npro2-to-nutritionistpro-web" -f ~/.ssh/to-new-web
    cat ~/.ssh/to-new-web.pub
  2. On the new instance, append that one line to /home/admin/.ssh/authorized_keys (mode 600) and check it with ssh-keygen -lf ~/.ssh/authorized_keys; the fingerprint must match the one ssh-keygen printed on the old instance.

  3. Open port 22 on the new instance to the old instance's static IP, 54.162.107.22/32. put-instance-public-ports replaces the whole rule set, so the command restates the Cloudflare rules on 80 and 443 and the four Lightsail aliases on 22 too. From the Mac, with the default (nexgen_v3) profile:

    INSTANCE=nutritionistpro-web
    MY_IP=$(curl -s https://checkip.amazonaws.com)
    V4=$(curl -s https://www.cloudflare.com/ips-v4 | jq -R . | jq -s -c .)
    V6=$(curl -s https://www.cloudflare.com/ips-v6 | jq -R . | jq -s -c .)
    PORT_INFOS=$(jq -n -c --argjson v4 "$V4" --argjson v6 "$V6" --arg me "$MY_IP/32" --arg old "54.162.107.22/32" '[
    {fromPort: 22, toPort: 22, protocol: "tcp", cidrs: [$me, $old], cidrListAliases: ["lightsail-connect", "lightsail-connect-ipv6", "lightsail-setup-ipv4", "lightsail-setup-ipv6"]},
    {fromPort: 80, toPort: 80, protocol: "tcp", cidrs: ($v4 + [$me]), ipv6Cidrs: $v6},
    {fromPort: 443, toPort: 443, protocol: "tcp", cidrs: ($v4 + [$me]), ipv6Cidrs: $v6}
    ]')
    aws lightsail put-instance-public-ports --instance-name "$INSTANCE" --port-infos "$PORT_INFOS"
    aws lightsail get-instance-port-states --instance-name "$INSTANCE" --query "portStates[?fromPort==\`22\`].cidrs" --output text

    The last line must print both addresses. Run the same script without $old (and, after the pre-cutover tests, without $me on 80 and 443) once the transfer is done.

Export on the old instance​

Run the attack checks on PHP security stopgap (October 2026) right before the export, so a compromised site is not copied across. Then, on the old instance:

sudo wp --path=/opt/bitnami/wordpress db export - --exclude_tables=wp_redirection_404,wp_redirection_logs > /home/bitnami/site.sql
sudo wp --path=/opt/bitnami/wordpress db export - --tables=wp_redirection_404,wp_redirection_logs --where=1=0 > /home/bitnami/site-log-tables.sql
sudo tar -h -C /opt/bitnami/wordpress -czf /home/bitnami/wp-content.tar.gz --exclude=wp-content/ai1wm-backups --exclude=wp-content/updraft --exclude=wp-content/et-cache --exclude=wp-content/cache wp-content
sudo chown bitnami:bitnami /home/bitnami/wp-content.tar.gz
ls -lh /home/bitnami/site.sql /home/bitnami/site-log-tables.sql /home/bitnami/wp-content.tar.gz
grep -c "CREATE TABLE" /home/bitnami/site.sql /home/bitnami/site-log-tables.sql
tar -tzf /home/bitnami/wp-content.tar.gz | grep -c "^wp-content/uploads/"

Three Bitnami quirks shape these commands. The wp wrapper runs mysqldump as another user that cannot write into /home/bitnami, so the dump goes to standard output (-) and the shell writes the file. WP-CLI rewrites a --no-data flag into --data=, which mysqldump misreads, so --where=1=0 is what produces the CREATE TABLE statements with no rows. And /opt/bitnami/wordpress/wp-content is a symlink into /bitnami/wordpress, so without -h tar archives the link alone and produces a 130-byte file. et-cache is Divi's generated CSS and cache is WP Rocket's page cache; both are rebuilt on the new instance. Expect a CREATE TABLE count in the dozens for the first file and 2 for the second, roughly 300 MB of SQL, and over 1 GB of tar with thousands of upload entries; the tar takes a few minutes.

Push to the new instance​

Still on the old instance:

scp -i ~/.ssh/to-new-web /home/bitnami/site.sql /home/bitnami/site-log-tables.sql /home/bitnami/wp-content.tar.gz admin@35.174.58.107:~/

Answer yes to the host-key prompt on the first connection. The copy runs between two instances in the same region and takes a minute or two.

Import on the new instance​

The table prefix on both instances is wp_ (checked on 2026-10-21: $table_prefix in /var/www/wp-config.php), so the dump loads into the new instance's database as it is. The db reset drops the blueprint's fresh tables first, so nothing from the bundled plugins lingers; it runs against the new instance's throwaway database only, never the old one.

sudo -u www-data wp --path=/var/www/html db reset --yes
sudo -u www-data wp --path=/var/www/html db import - < ~/site.sql
sudo -u www-data wp --path=/var/www/html db import - < ~/site-log-tables.sql
sudo tar -C /var/www/html -xzf ~/wp-content.tar.gz
sudo chown -R www-data:www-data /var/www/html/wp-content
sudo find /var/www/html/wp-content -type d -exec chmod 755 {} \;
sudo find /var/www/html/wp-content -type f -exec chmod 644 {} \;
sudo -u www-data wp --path=/var/www/html core update-db
sudo -u www-data wp --path=/var/www/html rewrite flush
sudo -u www-data wp --path=/var/www/html plugin list --fields=name,status
sudo -u www-data wp --path=/var/www/html user list --role=administrator --fields=user_login
sudo -u www-data wp --path=/var/www/html option get home
rm ~/site.sql ~/site-log-tables.sql ~/wp-content.tar.gz

The - form reads the dump from standard input, which sidesteps www-data not being able to read files in /home/admin. The database credentials in wp-config.php stay as the blueprint generated them; wp db import loads into the database that wp-config.php names. No search-and-replace is needed because the domain does not change. Expected: two Success: Imported lines, the old site's active plugin set, the old site's administrators with no blueprint user account, and home equal to https://nutritionistpro.com. On 2026-10-21 all of that held.

The tar carries only wp-content, so the WordPress .htaccess does not come across, and the fresh image never had one because it shipped with plain permalinks. Without it every pretty permalink (and robots.txt, the sitemap, and llms.txt) returns Apache's own 404 page while index.php?rest_route=... works. wp rewrite flush --hard refuses to write the file ("requires special configuration"), so write the standard block by hand. mod_rewrite is loaded and apache2.conf already has AllowOverride All for /var/www/html on this image, so nothing else is needed:

sudo tee /var/www/html/.htaccess >/dev/null <<'EOF'
# BEGIN WordPress
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
# END WordPress
EOF
sudo chown www-data:www-data /var/www/html/.htaccess
sudo chmod 644 /var/www/html/.htaccess

What the tar does not carry​

The dump and the tar move the database and wp-content. Three things live outside them and showed up as critical items in Site Health on 2026-10-21 until they were dealt with:

The core files. The image shipped WordPress 7.1 while the old site was on 7.1.3, so Site Health reported a security update. sudo -u www-data wp --path=/var/www/html core update followed by core update-db brings the files up; /var/www/html is writable by www-data on this image.

The salts and the cache constant in wp-config.php. Site Kit encrypts its Google credentials with LOGGED_IN_KEY and LOGGED_IN_SALT, so with the image's fresh salts it reports site_status: not-connected and would need a full re-authorization. Copying the eight salt lines from the old file keeps it connected (and invalidates everyone's login cookies once, which is fine). The image also defines WP_CACHE as false, which turns WP Rocket off and makes Site Health report no page cache; it has to be true. On the old instance, pull the salts and push them the same way as the content, then remove the copy:

sudo grep -E "define\( *'(AUTH|SECURE_AUTH|LOGGED_IN|NONCE)_(KEY|SALT)'" /bitnami/wordpress/wp-config.php > /home/bitnami/salts.php
wc -l /home/bitnami/salts.php
scp -i ~/.ssh/to-new-web /home/bitnami/salts.php admin@35.174.58.107:~/ && shred -u /home/bitnami/salts.php

On the new instance, splice them in place of the image's salts, replace the two constants, and check the result (WP_MEMORY_LIMIT is raised from the 40M default to the 256M Divi recommends):

sudo cp /var/www/wp-config.php /var/www/wp-config.php.bak2
sudo python3 - <<'EOF'
import re
p = '/var/www/wp-config.php'
s = open(p).read()
salts = open('/home/admin/salts.php').read().strip() + '\n'
assert salts.count('define') == 8
s = re.sub(r"define\( *'(AUTH|SECURE_AUTH|LOGGED_IN|NONCE)_(KEY|SALT)'.*\n", '', s)
s = re.sub(r"define\( *'(WP_CACHE|WP_MEMORY_LIMIT)'.*\n", '', s)
marker = "/* That's all, stop editing!"
assert s.count(marker) == 1
block = salts + "define( 'WP_CACHE', true );\ndefine( 'WP_MEMORY_LIMIT', '256M' );\n"
open(p, 'w').write(s.replace(marker, block + marker, 1))
print('wp-config updated')
EOF
sudo php -l /var/www/wp-config.php && sudo rm /home/admin/salts.php
sudo grep -cE "define\( *'(AUTH|SECURE_AUTH|LOGGED_IN|NONCE)_(KEY|SALT)'" /var/www/wp-config.php

The count must be 8. Never paste the salts into a chat or a runbook.

The spare themes and the inactive plugins. The tar does not delete anything already on disk, so the image's bundled plugins (all-in-one-seo-pack, amp, google-analytics-for-wordpress, hello, simple-tags, w3-total-cache, wp-mail-smtp) and its six default themes stay, and the old site's own inactive plugins come across with it (listed on Hosting and deployment). None of them is active after the import, but W3 Total Cache conflicts with WP Rocket and All in One SEO with Yoast if anyone activates them later, so delete them, keeping one default theme as WordPress's fallback:

sudo -u www-data wp --path=/var/www/html theme delete twentynineteen twentytwenty twentytwentyone twentytwentythree twentytwentyfour
sudo -u www-data wp --path=/var/www/html plugin delete akismet all-in-one-seo-pack amp arprice-responsive-pricing-table amazon-polly email-marketing google-analytics-for-wordpress hello simple-tags tidio-live-chat w3-total-cache wp-optimize wp-mail-smtp
sudo -u www-data wp --path=/var/www/html plugin list --status=inactive --field=name

Jetpack's "Connection Tokens" item in Site Health stays until after cutover, because WordPress.com has to reach the site at its public URL to re-establish the connection; it is on the post-cutover list.

Afterward, in wp-admin:

  1. Log in with an admin account from the old site. The import replaced the users, so the blueprint's user account is gone (confirm under After the move).
  2. Save Settings > Permalinks once without changing anything, so the rewrite rules are rebuilt.
  3. Open WP Rocket, clear the cache, and save its settings once so it writes its rules into .htaccess again.

Alternative: All-in-One WP Migration​

For a smaller site, or with the plugin's paid Unlimited extension, the same move is All-in-One WP Migration > Export > File on the old site and Import > File on the new one, where the plugin is pre-installed. Raise upload_max_filesize and post_max_size in /etc/php/8.2/apache2/php.ini above the export's size and restart Apache first, put them back afterward, then do the three steps above. The free plugin's import cap is far below this site's 2.5 GB, which is why it is not the route used.

Re-apply configuration​

  • wp-config.php (at /var/www/wp-config.php on the new layout): confirm the WP_HOME, WP_SITEURL, and HTTP_X_FORWARDED_PROTO block from WordPress configuration is in place (step 6 above), plus WP_AUTO_UPDATE_CORE. Copy across any other custom constants the inventory lists under wp config list, except the database credentials and salts, which stay the new instance's own.
  • PHP settings: none of the stopgap settings from the old instance (memory_limit = -1, the lowered post_max_size, pm.max_requests) belong on the patched instance. Leaving session.upload_progress.enabled = 0 in place is harmless.
  • PHP limits: Debian's /etc/php/8.2/apache2/php.ini ships with upload_max_filesize = 2M, post_max_size = 8M, memory_limit = 128M, and max_execution_time = 30, which are too small for Media Library uploads and Divi. Set them to 64M, 64M, 256M, and 120 with sudo sed -i on that file and sudo systemctl reload apache2. php -i on the command line reads the separate cli/php.ini (where memory_limit is -1 and the time limit 0), so it does not show what Apache runs with; check the file itself or Site Health's Info > Server.
  • Apache ServerName: the image sets none, so every start logs AH00558 and every TLS handshake logs ssl:warn AH01909 (certificate does not match the link-local IPv6 address Apache picked as its name). Fix with echo "ServerName nutritionistpro.com" | sudo tee /etc/apache2/conf-available/servername.conf, sudo a2enconf servername, sudo apache2ctl configtest, and a reload.
  • WP Mail SMTP: open its settings and send the test email. Re-enter the credentials if they were constants on the old instance.
  • Licenses: re-activate Divi, WP Rocket, and Gravity Forms.
  • WPCode: confirm both Freshsales snippets are present and active under Code Snippets.
  • WP Rocket: clear the cache and let it preload. Its CDN and host-specific settings may need re-saving.
  • Yoast SEO: confirm the sitemap renders and the Sitemap: line in robots.txt is https://.
  • Website LLMs.txt: Tools > Llms.txt > Generate Now.
  • Cron: WordPress cron runs on page loads by default; if the old instance had a system cron calling wp cron event run, recreate it (the inventory's crontab -l output shows whether it did).

Test the new origin before cutover​

Send requests straight to the new static IP with the real hostname, from a local machine. The -k flag is needed when the origin has a Cloudflare Origin CA certificate, which public clients do not trust:

NEW_IP=<new static ip>
curl -sk --resolve nutritionistpro.com:443:$NEW_IP -o /dev/null -w "%{http_code} > %{redirect_url}\n" https://nutritionistpro.com/
curl -sk --resolve nutritionistpro.com:443:$NEW_IP -o /dev/null -w "%{http_code}\n" https://nutritionistpro.com/wp-login.php
curl -sk --resolve nutritionistpro.com:443:$NEW_IP https://nutritionistpro.com/robots.txt | grep Sitemap
curl -sk --resolve nutritionistpro.com:443:$NEW_IP https://nutritionistpro.com/sitemap_index.xml | grep -o '<loc>[^<]*' | head -5
curl -sk --resolve nutritionistpro.com:443:$NEW_IP -o /dev/null -w "%{http_code}\n" https://nutritionistpro.com/llms.txt
curl -sk --resolve nutritionistpro.com:443:$NEW_IP https://nutritionistpro.com/wp-json/ | python3 -c "import sys,json;d=json.load(sys.stdin);print(d['url'],d['home'])"

The firewall must admit your address on 443 for the duration of these checks. Expected: the homepage returns 200 with no redirect, the login page 200, every URL https://nutritionistpro.com/..., and url and home both https://nutritionistpro.com.

Then, in a browser with the hostname pinned to the new IP (an entry in /etc/hosts, removed afterwards). On a Mac: sudo sh -c 'echo "35.174.58.107 nutritionistpro.com" >> /etc/hosts', then sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder; the browser warns about the certificate because the Origin CA is trusted only by Cloudflare, which is expected here and nowhere else. Remove the line afterwards with sudo sed -i '' '/35.174.58.107 nutritionistpro.com/d' /etc/hosts and flush again. The http:// image URLs in the homepage's client logo strip are in the page content on the live site too, so they are not a migration regression.

  • Log in to wp-admin and open Tools > Site Health. No critical issues, and PHP 8.x reported. Under Info > Server, the PHP version must be the one the package changelog confirmed as patched in The patched PHP gate.

  • Open the homepage, the pricing pages, the Release Notes page, a blog post, and a video page. Compare them with the live site.

  • Submit the Diet Analysis Module trial form with a test entry and confirm the note on the entry in Forms > Entries shows the backend accepted it. See Trial sources. Delete the test contact in Freshsales afterwards.

  • On the new instance, check the Apache error log for PHP fatal errors:

    sudo tail -200 /var/log/apache2/error.log | grep -i "fatal\|deprecated" | head

Cut over​

Done at about 19:05 UTC on 2026-10-21. The signal that the switch has happened is the x-powered-by: PHP/7.4.24 header disappearing from curl -sI https://nutritionistpro.com/: the old Bitnami origin sent it and the new one sends nothing.

  1. Stop editing content on the old site, and re-run the export and import if anything changed since the first import.

  2. In Cloudflare, DNS > Records on nutritionistpro.com: change the apex A record from 54.162.107.22 to the new static IP. Keep it proxied. It is the only record that points at the origin: there is no AAAA record, and blog, docs, and ftp point elsewhere (see Domain and DNS), so nothing else changes. Do not add an AAAA record for the new instance's IPv6 address.

  3. Nothing changes at GoDaddy, on the /docs Worker, on the Pages projects, or on the Nexgen V3 records (app, trial, api, mcp, dev).

  4. Within a minute, run the verification in WordPress configuration from a local machine, plus:

    curl -sI https://nutritionistpro.com/docs/ | head -1
    curl -sI https://nutritionistpro.com/ | grep -i x-powered-by

    The docs path must still return 200. The Lightsail image does not send an x-powered-by header (expose_php is off), so the PHP version is confirmed in wp-admin under Tools > Site Health > Info > Server instead.

  5. Log in to wp-admin through the real domain and confirm Site Kit and Jetpack still report as connected. There is nothing to resubmit in Search Console: the URLs have not changed.

Rollback​

Change the apex A record back to 54.162.107.22. The old instance is untouched until the decommission step, so rollback is that one edit.

After the move​

Status on 2026-10-21 evening: the user account check passed (the five old administrators, no user), Jetpack was reconnected, the firewall was tightened, and the runbooks, skills, and memory were updated. The rest of this list is open.

  • Reconnect Jetpack from the Site Health item (Reconnect now) once DNS points at the new instance; WordPress.com has to reach the site at its public URL, so this cannot be done before cutover. Reload Site Health afterwards: the loopback, cron, and page-cache items clear once the site can reach itself through Cloudflare.
  • Tighten the new instance's firewall: rerun the script under Prepare the push from the old instance without the old instance's address on 22 and without the operator's address on 80 and 443.
  • Run the pending plugin and theme updates from wp-admin (Divi, WP Rocket, WP Mail SMTP Pro, Microsoft Clarity), clear the WP Rocket cache, and spot-check the homepage and a trial form page.
  • Optional cleanups: delete the deactivated Drip add-on and consider removing UpdraftPlus (nothing scheduled); sudo apt-get install -y php-imagick && sudo systemctl restart apache2 so WordPress uses Imagick instead of GD; and replace the hardcoded http://nutritionistpro.com image URLs in page content with https:// (sudo -u www-data wp --path=/var/www/html search-replace 'http://nutritionistpro.com/' 'https://nutritionistpro.com/' --dry-run first, then without --dry-run, then clear the WP Rocket cache) to end the browser's mixed-content warnings. Consider turning off the Redirection plugin's 404 log or capping its retention so the database does not regrow.
  • Deal with the admin passwords. The content import is expected to replace the users, which removes the blueprint's default user account and its password from ~/application_credentials; confirm that with sudo -u www-data wp --path=/var/www/html user list --role=administrator on the new instance, and if user survived, delete it (wp user delete user --reassign=<your user id>) or at least give it a new password. Then change your own administrator password from wp-admin (Users > Profile > Set New Password) or with sudo -u www-data wp --path=/var/www/html user update <login> --user_pass='<new password>', because the build and import steps pass credentials through terminals and transcripts. Store the new passwords in the password manager, never in these runbooks.
  • Keep the old instance stopped (not deleted) for an agreed period, for example two weeks. A stopped instance without a static IP loses its public IP, which is fine since Cloudflare no longer points at it; a static IP keeps billing while unattached, so release the old static IP once rollback is no longer needed.
  • Take a final manual snapshot of the old instance, then delete the instance. Keep the snapshot until the old tenant is closed.
  • Update these runbooks: the instance details on Hosting and deployment, the A record on Domain and DNS, the key, user, hostname, and every /opt/bitnami path on SSH access and WordPress configuration, the tenant warnings on every page in this section, the "Still open" list on Changes made in 2026 plus a dated entry, and a closing note on PHP security stopgap (October 2026).
  • Update the nutritionistpro-wordpress-hosting note in the agent memory and the deploy-docs and runbooks skills in the Axxya-Systems/docs repo, which also name the old tenant and the Bitnami layout.
  • Close issue 713 and the website part of issue 746.

Alternative: export the snapshot to EC2​

If the theme or a plugin cannot run on PHP 8 and there is no time to replace it, the old stack can be moved as is. Since 2026-10-21 this is a last resort: it carries PHP 7.4 with the two unfixed critical vulnerabilities across, so the stopgap measures would have to be re-applied on the EC2 instance and the fresh-instance move planned again at once.

  1. In the old tenant's Lightsail console, open the instance snapshot and choose Export to Amazon EC2. This creates an AMI and an EBS snapshot in the old tenant's EC2 console.
  2. In EC2, share the AMI and its snapshot with the nexgen_v3 account id (AMI > Actions > Edit AMI permissions).
  3. In the nexgen_v3 tenant, launch an EC2 instance from the shared AMI, give it an Elastic IP, and put it in a security group that admits only Cloudflare's ranges on 80 and 443.
  4. Cut over the Cloudflare A record to the Elastic IP as above.

This lands on EC2, not Lightsail, with EC2 pricing and without Lightsail's snapshot scheduling, and it carries the unsupported PHP 7.4 stack with it. Treat it as a stopgap and plan the fresh-instance move again afterwards.