Moving a website to another server tends to get postponed until the last minute – “it works, doesn’t it?”. Our case: an old server on Ubuntu 18.04 with a hosting control panel that’s long been abandoned, and PHP 7.4, which stopped receiving security updates back in November 2022. PHP 8 can’t be installed on that OS release from the standard repositories anymore, so there was only one way forward – a new server. Below is the checklist we used to move a site and its headless CMS with zero downtime, plus the traps worth knowing about.
Step 0. Audit: what is actually alive on the server
Before moving anything, find out what’s worth moving. We checked every domain on the server: does it resolve in DNS, what does it return over HTTPS, when does its certificate expire.
dig +short A example.com @1.1.1.1
curl -s -o /dev/null -w '%{http_code}' https://example.com/
The result was surprising: half of the domains no longer existed (NXDOMAIN), yet their files, databases and PHP processes were still running on the server. Two more sites had certificates that had expired months earlier, and one returned 502 because its PHP pool had died without anyone noticing. Old WordPress installs that nobody visits are a perfect target for attackers, so cleaning up is a security task too.
Step 1. Archive properly – and verify the archive
An archive nobody has checked isn’t an archive, it’s a hope. For each site we saved files, a database dump and web server configs separately:
tar czf site.files.tar.gz -C /home/user/web site.com
mysqldump --single-transaction --routines --triggers db_name | gzip > site.db.sql.gz
Then we verified everything before deleting a single byte:
gzip -ton every archive – is it intact;- the file count in the archive matches the file count on disk;
- the number of
CREATE TABLEstatements in the dump matches the number of tables in the live database, and the dump ends withDump completed.
Only when every check passed did deletion happen. The script we ran simply stopped at the first mismatch.
The trap: bulk actions in a control panel
While removing several dead domains in a row, the panel restarted PHP-FPM after each one – even though it was explicitly told not to. Within seconds systemd decided there were “too many restarts” and blocked the service (start-limit-hit). Every PHP site on the server returned 502 for about a minute, until we cleared the block.
systemctl reset-failed php7.4-fpm
systemctl start php7.4-fpm
Lesson: run bulk operations with pauses, or with a single restart at the end. And always confirm afterwards that the live sites still return 200.
Step 2. The new server: isolation from day one
The new server runs Ubuntu 24.04, nginx, PHP 8.4 and MySQL 8. Instead of a panel – plain configs in which every site is isolated:
- a dedicated system user and a dedicated PHP-FPM pool for the CMS – compromising one site gives no access to another;
- a dedicated database user with rights to its own database only and a fresh password;
- no PHP execution inside the uploads folder, and
xmlrpc.phpblocked if you don’t need it.
Before the move, check that your code works on the new PHP version. Plugins usually get updated on their own; your custom theme, small in-house plugins and code snippets don’t. The simplest check is to run them through php -l of the target version before migrating.
Step 3. Copy – and prove it’s identical
We streamed the database and files straight from the old server to the new one, then compared table and row counts, and file counts. But the most useful check is to compare API responses from the old and new servers before touching DNS. You don’t need to change anything for that: curl --resolve sends the request to a specific IP with the correct domain.
curl -s https://cms.example.com/wp-json/wp/v2/posts | md5sum
curl -s --resolve cms.example.com:443:NEW_IP
https://cms.example.com/wp-json/wp/v2/posts | md5sum
Identical hashes for posts, pages and menus – and you can sleep well.
Step 4. SSL that works from second one
A classic problem: DNS has already switched, but the new server has no certificate yet, because Let’s Encrypt can’t validate a domain that still points to the old IP. The answer is to temporarily copy the valid certificates from the old server. HTTPS works the moment you switch, and you issue your own certificates with certbot once DNS has updated. We used the webroot method – it doesn’t touch the nginx configs, and auto-renewal just works afterwards.
Step 5. DNS: switch, then check from everywhere
Lower the TTL of your records a week before the move (ours was 15 minutes – that’s enough). After the change, don’t just check your own machine – check the authoritative name servers and public resolvers:
dig +short A example.com @ns1.your-dns.com
dig +short A example.com @1.1.1.1
dig +short A example.com @8.8.8.8
A non-obvious one: the new server has a DNS cache of its own. For several minutes after the switch, WordPress kept calling its own wp-cron on the old IP. One command fixes it: resolvectl flush-caches.
Step 6. Deployment: change secrets together with DNS
Our frontend deploys from GitHub Actions over SSH. On the new server we created the same deploy user with the same public key, so only two secrets had to change – the server address and the path. The key point: change them at the moment you switch DNS, not earlier – otherwise the next git push deploys the site where no traffic is going yet.
A bonus finding during the review: the git remote URL contained a personal GitHub token in plain text, with access to the whole account. We revoked it, switched git to authenticate through the GitHub CLI, and created a new fine-grained token for the CMS webhook with access to a single repository only.
Step 7. Don’t delete the old setup right away
We didn’t delete the old sites – we suspended them: files and databases stayed on disk, with the archive right next to them. A rollback takes a minute: point DNS back and unsuspend. The old server is switched off for good only once everything has been proven in production: deployments, the contact form, webhooks.
The checklist in one list
- Audit domains: DNS, HTTP status, certificates.
- Archive every site, then verify the archive.
- Bulk actions with pauses, then check the live sites.
- A new server with per-site isolation and separate database users.
- Check your code against the new PHP version before moving.
- Copy and compare: counts, plus API response hashes via
curl --resolve. - Temporary certificates before the DNS switch, certbot afterwards.
- Lowered TTL, checks from several resolvers, flush the server’s DNS cache.
- Deployment secrets – at the moment of the switch.
- Suspend the old setup instead of deleting it.
Planning a server move?
I’ll audit your current server, move your site and CMS with zero downtime, set up SSL, automated deployment and a rollback plan – and switch the old server off only once everything has been proven in production.