In 2024 I moved my WordPress sites onto a cheap VPS and wrote about how I got there. On 9 September 2026 I moved the blog off it. The provider’s control panel said Last Backup: None. The disk was 95% full, with 1.6 GB free, and nearly half of what was on it was old backups.
I couldn’t count any of that until I got back into the server, and I’d forgotten the root password.
How do you get back into a VPS when you’ve forgotten the root password?
I had an SSH key for BinaryLane saved in my password manager. It had no hostname on it, and Cloudflare hid the origin IP of both my sites. I had Claude Code try certificate transparency logs, DNS history and old email headers. None of them turned up the address. A screenshot of the control panel did.
Then the server rejected every key I owned. SSH password logins were off, so the root password in the panel wasn’t going to help over SSH either, and I’d forgotten it anyway. This is what it took:
- Reset the root password in the panel, then restart the VM. The panel flags a pending change with a warning triangle. Until I restarted, the web console kept saying
Login incorrect. - Type words, not a key, because the console won’t take a paste. Cmd+V did nothing in the browser console, and hand-typing a 68-character random key is a typo waiting to happen. GitHub publishes your public keys at a URL made of words:
mkdir -p /root/.ssh && chmod 700 /root/.ssh && curl -fsSL https://github.com/<username>.keys >> /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys && echo DONE
- Fix whatever stops root logging in. The server now accepted my key and still wouldn’t let root in.
PermitRootLoginwasnoin the mainsshd_configand again in a drop-in the provider had added. The top ofsshd_configincludessshd_config.d/*.conf, and the man page says the first value for each keyword wins, so a file called00.confgoes ahead of everything else.prohibit-passwordlets keys in and keeps passwords out:
echo PermitRootLogin prohibit-password>/etc/ssh/sshd_config.d/00.conf;systemctl restart ssh
If your console takes a paste, put sshd -t && in front of the restart, so a typo can’t take SSH down with it.
It took 43 minutes from my first message to a working shell. After that I told Claude Code to look and not touch. The audit was read-only, and the only things I changed on the server were what it took to get in: the password, the key list and that one SSH setting. The disk was too full to stage anything on it, so every dump and tarball streamed over SSH straight to my Mac.
What was actually on the VPS?
I expected two WordPress sites. The blog was the small one: eight plugins (one inactive), the stock Twenty Twenty-Four theme and nothing custom to port. It had 17 published posts, 3 drafts, 11 approved comments and 2,723 spam comments waiting for moderation. It even had a LiteSpeed cache plugin running on a server that was serving nginx.
Old backups took 12.6 GB of the 27. There was also stuff I’d forgotten I built: a Postgres database of 601 scraped job listings (last scraped on 30 September 2024), an Apache Superset I ran by hand with 11 dashboards and 96 charts, and five Docker containers that had been dead since June 2024.
There was other people’s data on there too. It isn’t mine to describe, so I won’t.
The site I came to retire was under 2% of the disk.
Which old WordPress backups are worth keeping?
The old backups came in three sets, all from around my 2024 move off Lightsail: two full-site backup sets made by a plugin, and a 4.5 GB snapshot from a migration plugin. I pulled all of them down, hashed every file with SHA-256 and sorted each one into a bucket: identical to what’s on the live site today, a copy of something I was already keeping, a resized thumbnail whose original exists (WordPress regenerates those), or new.
The snapshot had the first set’s nine zips inside it, byte for byte, and they made up about half its weight. It was a backup of a backup. That left 37,607 loose files, and 9 of them were new: 155 KB between them. Another zip, nearly 4 GB, held nothing but that snapshot again.
Across all three sets I kept 27,332 files: about 750 original images that had since been deleted from the live site, and roughly 26,000 old plugin and theme files.
The database dumps were the other big one: 818 of them, 1.7 GB gzipped and about 17 GB unpacked. Consecutive dumps are nearly identical, so compressing each file on its own wastes almost everything. Packing them into one zstd stream with a long window got them to 356 MB. A trial on 120 of them took 244 MB of gzip down to 20 MB.
mkdir sql && for f in gz/*.gz; do gunzip -c "$f" > "sql/$(basename "$f" .gz).sql"; done # unpack
tar -cf - sql | zstd -T0 -12 --long=28 -o dumps.tar.zst # pack
zstd -dc --long=28 dumps.tar.zst | tar -tf - | grep -c '\.sql$' # 818
The unpack step needs about 17 GB free, and on a Mac you’ll want brew install zstd first.
Nothing counted as backed up until I’d restored it. Every full dump I took from the live databases (six MariaDB, one Postgres) went into a throwaway Docker container, and I compared COUNT(*) per table against the live server. For the blog, 62 of 62 tables came back and every data table matched. The only differences were tables that change every minute on a live WordPress site, like the scheduler and options tables. The 818 dumps only got a lighter check: the archive lists all 818 and the newest one unpacks complete.
The finished archive folder is 1.7 GB, with a SHA256SUMS.txt covering all 128 files in it. I re-ran the check while writing this and got 128 of 128 OK.
How many of my newsletter subscribers had confirmed?
MailPoet said I had 393 subscribers, and the CSV export agreed. The statuses didn’t:
- 78 subscribed (they’d clicked the confirmation link)
- 275 unconfirmed
- 40 bounced
The timing explains most of it. In the nine months from May 2024 to January 2025 I got 79 signups and 54 of them confirmed. From late October 2025 to January 2026 I got 311 signups: 24 confirmed, 37 bounced and 250 never clicked the link. That looks like bots to me, though I can’t prove it. 24 of the 78 confirmations came during that wave too, 17 of them in January 2026 alone, so even 78 may be generous.
The list’s whole career is one newsletter, sent on 8 July 2024 to 29 people.
I imported only the 78 confirmed addresses into Kit, on the free plan, under their own tag. The other 315 are archived in the CSV and stay there, because an address that never said yes isn’t one I should mail. MailPoet was double opt-in too, which is the only reason those 275 got labelled unconfirmed instead of getting my emails. The number on the dashboard was the vanity number. Kit’s form is double opt-in as well, so I’ll be watching for the same thing there.
Migrating WordPress to Astro: rebuild from the live site, not your copy
The Astro half wasn’t from scratch. The repo had existed since March 2026, set up for Cloudflare Pages, with my articles already in it. The work on 9 September was making it match WordPress, redrawing the images, wiring up the mailing list and switching DNS. It went live at 5:15pm.
The articles in the repo weren’t mine any more. Earlier AI-assisted rewrites had drifted. The DNS post was roughly half the length of the live one (996 words against 1,891 by wc -w on the files). The Mindful Coder’s Workweek had lost its TL;DR and its Sources section, and two placeholder posts were in there that had never been on the live site. Nobody had checked them against the real thing yet.
So I threw the repo copies away and re-converted all 17 posts from the live site’s HTML, keeping headings, tables, links and the original dates (18 May to 15 July 2024). The live page is what readers actually saw, so that’s the reference.
Old URLs sat at the root as /post-name/ and new ones live under /posts/. Cloudflare Pages reads a _redirects file, one rule per line:
/min-maxing-free-wordpress-hosting /posts/min-maxing-wordpress-hosting 301
/min-maxing-free-wordpress-hosting/ /posts/min-maxing-wordpress-hosting 301
/feed /rss.xml 301
/wp-login.php / 301
All 17 old article URLs work, but each takes two hops, because Pages adds the trailing slash itself:
$ curl -sIL https://jonahdevs.com/min-maxing-free-wordpress-hosting | grep -iE '^(HTTP|location)'
HTTP/2 301
location: /posts/min-maxing-wordpress-hosting
HTTP/2 308
location: /posts/min-maxing-wordpress-hosting/
HTTP/2 200
Everyone lands in the right place, but pointing the rule straight at the trailing-slash URL would save a hop.
Email moved too: incoming mail now goes through Cloudflare Email Routing to my Gmail instead of Google Workspace, and the audit had found the domain had no SPF record at all (SPF is one of the TXT records in my six DNS concepts post). Email Routing only handles incoming mail; the newsletter sends through Kit, which authenticates my domain with three CNAME records.
The first confirmation email from the new signup form landed in my spam folder, because the sender wasn’t authenticated yet. I authenticated the domain afterwards, and I haven’t gone back to measure where those emails land now.
Why can’t I delete the VPS yet?
I left the server running for 24 hours after launch and ran a post-launch check on 10 September. The redirects, RSS (17 items) and both custom domains all passed. Then I looked at what else was pointing at the box. The other site’s DNS still was, and the VPS was still serving it. Deleting or stopping the VM would take it offline. It still is.
The other site needs a migration or a static replacement before anything gets deleted, so the VPS stays up until I’ve sorted it out.
A checklist for retiring a WordPress VPS
-
Inventory the box, not just the sites. Disk, listeners, containers, databases. Mine held a Postgres database and a Superset I’d forgotten.
df -hT -x tmpfs -x devtmpfs du -xh --max-depth=1 /var ss -tlnp docker ps -a -
Stream backups to your own machine. If the disk is nearly full, don’t write the dump there first.
ssh root@your-server 'mysqldump --single-transaction dbname | gzip' > dbname.sql.gz -
Restore-test every dump somewhere disposable. Use the same MariaDB version as the source (mine was 10.11). Dumps from
mariadb-dump10.11.8 or later start with a sandbox-mode line that older MariaDB clients and the MySQL client reject. Then run the sameCOUNT(*)against the live database and compare.docker run -d --name restore-test -e MARIADB_ROOT_PASSWORD=test -e MARIADB_DATABASE=site mariadb:10.11 until docker exec restore-test healthcheck.sh --connect --innodb_initialized 2>/dev/null; do sleep 2; done gunzip -c dbname.sql.gz | docker exec -i restore-test mariadb -uroot -ptest site docker exec restore-test mariadb -uroot -ptest site -e 'SELECT COUNT(*) FROM wp_posts' docker rm -f restore-test -
Checksum the folder, dedupe it, and re-check after you copy it anywhere. Hash every file and keep one copy of each, because old backups are very often backups of each other.
find . -type f ! -name SHA256SUMS.txt -exec shasum -a 256 {} + > SHA256SUMS.txt shasum -a 256 -c SHA256SUMS.txt -
Find out what else depends on the box. Search every DNS zone you own for its IP, and read
nginx -Tfor every hostname it answers to. -
Import only the confirmed email list, and keep the server for a day or more after cutover. Keep the full export in the archive and mail only the people who said yes. Recheck redirects, RSS and mail, and only then delete the VPS. If it has to stay, undo the keys and SSH overrides you added to get in. Either way, rotate every secret that lived in
wp-config.phporwp_options. Your dumps hold working copies, so treat the archive folder like a password vault.
I’d run step 5 before step 1 next time. “Retire the server” sounded like one job. It was two sites, and I finished one.