Migrating a WordPress Droplet to Ubuntu 24.04 + PHP 8.3: The Clean Path, With the Landmines Marked

My blog was running on a DigitalOcean WordPress Droplet I’d spun up three years ago. It still worked, but two things underneath it had quietly gone end-of-life: Ubuntu 20.04 (out of standard support) and PHP 7.4 (EOL, and slower and less secure than current PHP). “Update PHP” turned out to really mean “migrate the whole stack” — and the clean way to do that is not to upgrade in place, but to build a fresh box alongside the old one and cut over when it’s proven.

That approach is the single most important decision in this whole process: the old site keeps serving readers the entire time, and “rollback” just means “don’t change DNS.” You’re never operating on the live patient.

Here’s the path that worked, with the places it tried to break flagged as you go.

Step 0: Two backups, before touching anything

Take both:

  • A full-server snapshot from your host’s dashboard (DigitalOcean: Droplet → Snapshots → Take Snapshot). This restores the entire old box if needed.
  • A WordPress-level export via a migration plugin (I use WPvivid). This is the portable copy you’ll restore onto the new box.

Then download the WordPress export to your own machine. A backup that only lives on the server you’re migrating away from is not a backup.

Landmine — the backup that isn’t. It’s easy to think “the plugin says backup complete” means you’re safe. If that file only exists in wp-content/ on the old Droplet, it dies with the Droplet. Download it locally before you proceed.

Step 1: Build the new Droplet

Create a fresh Droplet from the current WordPress on Ubuntu 24.04 Marketplace image — it ships PHP 8.3 and a modern stack, solving both EOL problems at once. Match your old size (1GB is fine for a small blog), same region, and note the new public IP.

Worth knowing — your web server may surprise you. Older WordPress images ran Apache; current ones may run Nginx, Caddy, or OpenLiteSpeed. Don’t assume. Mine came up on Caddy + PHP-FPM, which mattered later for both restarts and TLS. Find yours with systemctl list-units --type=service --state=running | grep -iE 'apache|nginx|caddy|litespeed'.

Step 2: Get the backup onto the new box

Install WordPress on the new Droplet (complete the basic install wizard via its IP), then install your migration plugin and upload your backup file to it. For a small site the plugin’s upload works; for larger files, SCP it straight to the server instead.

Step 3: Restore — and the fallback when the plugin stalls

Run the plugin’s restore. On a small (1GB) box, this is the step most likely to fight you.

Landmine — restore stalls at 80-something percent. A plugin restore that freezes near the end is almost always memory pressure on a small box. Two fixes, in order:

  1. Add swap (the highest-leverage fix on a 1GB box):
fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
Bash

Raise PHP limits in the web-server PHP config (find it with php --ini, but note the web config, e.g. the fpm one, not cli): set memory_limit = 512M and max_execution_time = 600, plus WP_MEMORY_LIMIT in wp-config.php. Restart PHP-FPM and your web server.

If the plugin restore still won’t finish after that — and it may not — don’t keep re-running it. Switch to a manual restore, which bypasses the plugin’s PHP-bound process entirely and basically always works. This was the part that actually got me through:

The migration backup is usually a set of component zips (database, uploads, themes, plugins). Restore them directly:

# 1. Import the database directly (skips the plugin's web process entirely)
cd /path/to/backup/
unzip -o *_db.zip -d /tmp/restore/
mysql -u DB_USER -p DB_NAME < /tmp/restore/your-backup_db.sql
 
# 2. Unzip the file components over the new install
unzip -o *_uploads.zip -d /var/www/html/
unzip -o *_themes.zip  -d /var/www/html/
unzip -o *_plugin.zip  -d /var/www/html/
 
# 3. Fix ownership so the web server (not root) owns the files
chown -R www-data:www-data /var/www/html/
Bash

The database import via mysql has no web-server timeout hanging over it, so where the plugin choked at 85%, the direct import just takes its time and finishes. Skip restoring WordPress core from the old backup — the new box already has a clean, current version, and overwriting it invites version mismatches.

LANDMINE — the stuck .maintenance file. If a restore is interrupted, WordPress can be left in maintenance mode (“Briefly unavailable for scheduled maintenance”), locking you out of the site and admin. Fix: rm /var/www/html/.maintenance

Step 4: Test on the IP, before DNS

The new box’s database has your real domain baked in as the site URL, which makes it redirect away from the IP when you try to test. To view the new box as itself, temporarily override the URLs in wp-config.php:

define('WP_HOME','http://YOUR_NEW_IP');
define('WP_SITEURL','http://YOUR_NEW_IP');
PHP

LANDMINE — 2FA locks you out of the new box. If you run a 2FA plugin (I use Duo), it can block login on the migrated box because its callback is tied to the domain. To get in during testing, temporarily disable it by renaming its plugin folder over SSH: mv wp-content/plugins/PLUGIN wp-content/plugins/PLUGIN-OFF — rename it back before cutover.

Now load the new box by IP and verify everything: posts render, images load, your theme and page-builder content are intact, code blocks display, and the admin works. Run Settings → Permalinks → Save once — this regenerates rewrite rules, which usually need a refresh after a migration to avoid post 404s.

This is also the natural moment to do anything bigger you’ve been putting off — a theme change, plugin cleanup — because you’re working on an isolated box with the old one as a perfect fallback. You’ll never have a lower-risk window.

Step 5: Cut over (undo scaffolding first, then DNS)

Order matters. Remove the temporary scaffolding before flipping DNS, or the live site will try to serve itself on the IP and break:

  1. Remove the WP_HOME / WP_SITEURL IP overrides from wp-config.php.
  2. Re-enable the 2FA plugin (rename the folder back).
  3. Set up TLS for the new box (next section).
  4. Point your DNS A record(s) at the new IP.

Step 6: TLS behind a proxy (the Caddy + Cloudflare gotcha)

This is where I lost the most time, so it gets its own section.

LANDMINE — SSL handshake failed. My new box ran Caddy, which provisions HTTPS automatically. But the Marketplace image’s Caddyfile was configured to manage TLS for the IP address, not the domain — so when a browser asked for a cert for the domain, the handshake failed. Caddy can’t get a valid public certificate for a bare IP.

The fix has two parts. Point Caddy at the domain. Edit /etc/caddy/Caddyfile so the site blocks use your domain, not the IP:

http://example.com, http://www.example.com {
    redir https://{host}{uri} permanent
}
 
https://example.com, https://www.example.com {
    root * /var/www/html
    php_fastcgi unix//run/php/php8.3-fpm.sock
    file_server
    encode gzip
    try_files {path} {path}/ /index.php?{query}
}
Plaintext

Handle the CDN/proxy correctly. If you run Cloudflare with the proxy on (orange cloud), Caddy can’t complete a normal Let’s Encrypt challenge — Cloudflare intercepts it. Rather than fight that by toggling the proxy off and on, the clean, permanent solution is a Cloudflare Origin Certificate:

  1. Cloudflare → SSL/TLS → Origin Server → Create Certificate (covers your domain + wildcard, 15-year validity). Save the cert and key to the server.
  2. In the Caddyfile’s HTTPS block, point at them explicitly: tls /etc/caddy/certs/example.crt /etc/caddy/certs/example.key
  3. caddy validate --config /etc/caddy/Caddyfile, then systemctl reload caddy.
  4. Cloudflare → SSL/TLS → set encryption mode to Full (strict).

Now Cloudflare presents its own cert to visitors and trusts your origin cert on the back hop. No Let’s Encrypt-through-proxy gymnastics, no rate-limit risk, nothing to renew for years.

Validate the Caddyfile before reloading. An OCSP-stapling warning for a Cloudflare Origin Certificate is normal and harmless — those certs aren’t publicly issued, so there’s no OCSP URL. Ignore it.

Step 7: Confirm, then keep the old box on standby

Before trusting the cutover, remove any temporary hosts-file entry on your own machine (so you’re testing real DNS, not a local override), then load the live domain in a fresh window and confirm: clean HTTPS, no redirect loop, the site renders, and — critically — log into admin and confirm 2FA actually completes.

Landmine — the “exact copy” plugin prompt. Some plugins (via Freemius licensing) detect that the site URL changed and drop into “safe mode,” asking whether the new domain is a duplicate, the new home, or a separate site. Choose “the new home of” / Migrate — it’s the same site at a new address, and that carries licenses and settings over cleanly. The other options put plugins into reduced-function mode or reset their licenses.

Then the discipline that makes the whole parallel-box strategy pay off: power off the old Droplet, but don’t delete it for a week. It’s your instant rollback if anything surfaces. Destroy it (and the pre-migration snapshot) only after the new box has proven stable for several days.

The two lessons worth keeping

Everything above reduces to two principles that apply far beyond WordPress:

Build alongside, never in place. A parallel box means the live service is never at risk and rollback is free. The few extra dollars for a second Droplet for a week is the cheapest insurance you’ll ever buy.

Always have a manual fallback for the convenience tool. The migration plugin is great when it works and useless when it stalls at 85%. Knowing the manual path — import the database with mysql, copy the files, fix ownership — is what turns a dead end into a ten-minute detour.