Migrating WordPress to a new server without downtime
The order is what decides it: prepare the new server, copy the data, test under the real domain via the hosts file, issue the certificate in advance, and only then switch DNS. With a rollback path and the error messages you will actually see.
A WordPress migration rarely fails because of a single command. It fails because of the order. If you switch DNS first and copy afterwards, you are guaranteed a window in which visitors see either an error page or a half-populated installation. Do it the other way around and you can finish the new server in peace, test it under the real domain, and change the DNS record only at the moment when everything demonstrably works.
This article describes exactly that order, plus the places where things blow up in practice: serialized data in the database, collations when moving from MySQL to MariaDB, certificates without a DNS record that points at the new machine, and the way back in case something was missed after all.
The sequence that produces no downtime
The old site stays online until the very last moment. Nothing is switched off, nothing is deleted. The new server runs in parallel, gets tested, and only takes over once the DNS record has been changed.
- Lower the TTL of the A and AAAA records to 300 seconds, at least 24 to 48 hours in advance.
- Set up the new server: web server, PHP, database, users, directories.
- Copy the files, copy the database.
- Issue the certificate for the domain, even though DNS still points at the old server.
- Adjust the hosts file on your own machine and click through the site under the real domain.
- Run a delta sync shortly before the cutover so the last changes come along.
- Switch DNS. Leave the old server running for several more days.
The only point at which a gap can theoretically appear is step 6. How small that gap stays is decided by step 1.
Lower the TTL before anything else happens
The TTL tells resolvers worldwide how long they may cache an answer. If it sits at 86400, a resolver can keep handing out your old IP for another 24 hours after you have switched. And one detail is easy to overlook: a lowered TTL only takes effect after the old TTL has expired. If you go from 86400 down to 300, it can take a full day until every resolver knows the short value.
That is why this is the first step, not the last. dig is not part of the base installation on any distribution and is needed throughout this guide, so install it first:
apt install -y bind9-dnsutils
On Debian 11, Ubuntu 22.04 and Ubuntu 24.04 the package is still called dnsutils. From Debian 12 onwards, dnsutils is only a placeholder that points to bind9-dnsutils, and on Debian 13 it is a purely virtual package without a version of its own. Both work, because apt resolves the single provider by itself, but bind9-dnsutils is the name that will stay. Then check the current value:
dig +noall +answer example.com A
dig +noall +answer example.com SOA
The number in the second column of the A answer is the remaining TTL. Ask the authoritative server directly, otherwise you only see the leftover value from your own resolver's cache:
dig @a.ns14.net example.com A +noall +answer
After the migration, raise the TTL again to 3600 or more. Permanently short TTLs create unnecessary load and make outages at your DNS provider last longer.
Equipping the new server to match
This is where most of the nasty surprises come from, because the distribution on the new server ships different versions than the old one. All package commands in this guide assume Debian or Ubuntu. On AlmaLinux, Rocky Linux and Oracle Linux there is no apt and the package names differ. As of July 2026 the picture looks like this:
| System | PHP | Database | nginx |
|---|---|---|---|
| Debian 13 | 8.4 | MariaDB 11.8 (no mysql-server) | 1.26.3 |
| Debian 12 | 8.2 | MariaDB 10.11 | 1.22.1 |
| Ubuntu 24.04 | 8.3 | MySQL 8.0.46 or MariaDB 10.11 | 1.24.0 |
| Ubuntu 22.04 | 8.1 | MySQL 8.0.46 or MariaDB 10.6 | 1.18.0 |
Two consequences follow from this. First: on Debian you do not get mysql-server, MariaDB always runs there instead. If the old site ran on MySQL 8, moving to Debian is a database change at the same time, with a very concrete trap further down. Second: the jump from PHP 8.1 to 8.4 is not automatic. Older themes and plugins react to removed functions with a Fatal error: Uncaught Error: Call to undefined function or with a blank page. If the old installation ran on PHP 8.1 and you do not want to couple the migration with a PHP upgrade, Ubuntu 22.04 or a PHP version from a third-party repository is the quieter choice. What the table deliberately leaves out is Debian 11: there php-cli still ships PHP 7.4.33, a version without security maintenance and below what WordPress recommends. That rules Debian 11 out as a migration target, unless you pull in the Sury repository first.
The base packages for a typical installation with nginx and PHP-FPM:
apt update
apt install -y nginx php-fpm php-mysql php-xml php-curl php-mbstring php-zip php-gd php-intl
apt install -y mariadb-server mariadb-client rsync curl
Details on the web server are in the nginx guide, the basic hardening of the database in securing MariaDB and MySQL. If the server is brand new, it is worth a look at the checklist for new root servers beforehand.
Create the database and the user, with the same character set as on the source:
CREATE DATABASE wp_new CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'wp_new'@'localhost' IDENTIFIED BY 'a-long-password';
GRANT ALL PRIVILEGES ON wp_new.* TO 'wp_new'@'localhost';
FLUSH PRIVILEGES;
Transferring the files without wrecking the permissions
Copying happens directly from server to server, not across your home connection. The simplest way is rsync from the old server to the new one, with an SSH key instead of a password:
rsync -az --delete --exclude 'wp-content/cache/' --exclude 'wp-content/uploads/backup*' \
-e 'ssh -p 22' /var/www/html/ root@neue.ip:/var/www/html/
The first run is allowed to take a while. That is precisely why you do it days before the cutover, and shortly beforehand you simply run the same command once more, which then transfers only the differences.
After copying, owner and permissions need to be straightened out, because UIDs differ between systems and rsync transfers numbers, not names:
chown -R www-data:www-data /var/www/html
find /var/www/html -type d -exec chmod 755 {} \;
find /var/www/html -type f -exec chmod 644 {} \;
chmod 640 /var/www/html/wp-config.php
Two files get thrown out before the first start, if they are present: wp-content/object-cache.php and wp-content/advanced-cache.php. Both are drop-ins from caching plugins that point at a Redis or Memcached socket which does not exist yet on the new server. The result would be a blank page without any usable message.
Transferring the database and the collation trap
The dump works with WP-CLI or the classic way. WP-CLI has the advantage that it takes character set and prefix from wp-config.php:
cd /var/www/html
wp db export /root/wp-dump.sql --add-drop-table
Without WP-CLI, on a current MariaDB system the tool is called mariadb-dump. The old name mysqldump has been nothing more than a deprecated symlink since MariaDB 11.0 and announces itself with Deprecated program name. It will be removed in a future release:
mariadb-dump --single-transaction --default-character-set=utf8mb4 wp_alt > /root/wp-dump.sql
Now for the point where a migration from Ubuntu with MySQL 8 to Debian with MariaDB regularly falls flat. MySQL 8 uses the collation utf8mb4_0900_ai_ci by default, and that collation does not exist in MariaDB at all. The import aborts with:
ERROR 1273 (HY000) at line 42: Unknown collation: 'utf8mb4_0900_ai_ci'
The repair happens before the import, directly in the dump:
sed -i 's/utf8mb4_0900_ai_ci/utf8mb4_unicode_ci/g; s/utf8mb4_0900_as_cs/utf8mb4_unicode_ci/g' /root/wp-dump.sql
grep -c utf8mb4_unicode_ci /root/wp-dump.sql
Then import:
mariadb --default-character-set=utf8mb4 wp_new < /root/wp-dump.sql
If für appears everywhere instead of für after the import, the character set was lost during the dump or during the import. You do not repair that with search and replace, you repeat dump and import with --default-character-set=utf8mb4. This is exactly why the old database is still untouched at this point.
Next, adjust the credentials in wp-config.php on the new server: DB_NAME, DB_USER, DB_PASSWORD, and most importantly DB_HOST if the old installation pointed at a remote database server. If the old address stays in place, you get Error establishing a database connection even though everything works locally. If the connection refuses to come up despite correct credentials, fixing Access denied for user helps.
Replacing the domain, and why an SQL replace tears the site apart
First the good news: if only the server changes and the domain stays the same, you do not have to replace anything in the database. That is one of the reasons why testing via the hosts file is superior to testing via a temporary domain. Replacing only becomes necessary if the domain actually changes, if you combine the migration with the step from http to https, or if you do work with a test domain and have to replace back afterwards.
And now the reason why you never do this with a simple SQL statement. WordPress stores options, widget settings and theme configurations as serialized PHP arrays. A URL does not sit in there bare, it comes with its byte length in front of it:
s:19:"https://example.com"
If you replace example.com with neue-domain.de via UPDATE ... REPLACE(), the string gets longer while the number 19 stays where it is. PHP can no longer unpack the array afterwards, unserialize() returns false and the affected setting is gone. Typical symptoms: widgets disappeared, theme options reset, menu entries empty, and Notice: unserialize(): Error at offset in the logs.
WP-CLI solves this correctly because it unpacks the data, replaces it, and writes it back with a corrected length value. Always start with --dry-run:
wp search-replace 'https://alte-domain.de' 'https://neue-domain.de' --all-tables --precise --dry-run --report-changed-only
What the individual switches do: --all-tables also touches tables that do not follow the WordPress prefix, for example those of shop or form plugins. --precise forces the processing to happen in PHP instead of in SQL. It is slower, but it handles serialized data reliably. --report-changed-only trims the output down to the tables with actual hits.
If the preview looks plausible, run the same command without --dry-run. Then comes the pass that almost every guide skips: page builders such as Elementor store their content as JSON in the database, and slashes are escaped in there. Replacing https://alte-domain.de simply does not find https:\/\/alte-domain.de. Hence a second run:
wp search-replace 'https:\/\/alte-domain.de' 'https:\/\/neue-domain.de' --all-tables --precise --report-changed-only
With Elementor, regenerating the CSS files under Tools in the backend is part of the procedure as well, otherwise the generated stylesheets keep pointing at the old domain.
Two things search-replace does not reach: constants in wp-config.php and multisite. If define('WP_HOME', 'https://alte-domain.de'); is set there, it overrides every database value, and you spend hours searching in the wrong place. For multisite you additionally need --network and have to adjust the domains in wp_blogs and wp_site.
And a warning about the table prefix: do not change it during the migration. The prefix is also part of the option name wp_user_roles and of the user meta fields wp_capabilities and wp_user_level. If you rename only the tables, you can still log in, but afterwards you get Sorry, you are not allowed to access this page. and no longer have an administrator.
Issuing the certificate before DNS points anywhere new
There is a chicken and egg problem here. The usual HTTP validation from Let's Encrypt requires the domain name to already point at the validating server. At this stage it does not, because it is still supposed to serve the old site. If you start certbot --nginx anyway, you get:
Certbot failed to authenticate some domains (authenticator: nginx).
Invalid response from http://example.com/.well-known/acme-challenge/...: 404
The clean way out is DNS validation. It checks a TXT record _acme-challenge.example.com and does not care where the A record points:
certbot certonly --manual --preferred-challenges dns -d example.com -d www.example.com
Certbot displays a value that you enter as a TXT record in your DNS zone. Before you confirm, check for yourself whether the zone already serves that record, otherwise you burn a failed attempt:
dig +short TXT _acme-challenge.example.com
For wildcard domains, or if you want to automate this, the route via the DNS API is described in the article on the wildcard certificate. The manual variant does not renew itself, so switch to normal HTTP validation after the DNS cutover. From then on it works, because the domain now points at the new server.
Testing with the hosts file, like a real visitor
Now comes the part that makes the difference between "should work" and "does work". You redirect only your own machine to the new server, while the rest of the world keeps seeing the old site.
On Linux and macOS you edit /etc/hosts as root, on Windows C:\Windows\System32\drivers\etc\hosts in an editor started as administrator. Add a line with the IP of the new server:
203.0.113.10 example.com www.example.com
Then flush the DNS cache, otherwise the change does not take effect right away:
resolvectl flush-caches
On Windows use ipconfig /flushdns, on macOS sudo dscacheutil -flushcache followed by sudo killall -HUP mDNSResponder. Check whether it took effect:
getent hosts example.com
One pitfall that costs many hours: Firefox with DNS over HTTPS enabled ignores the hosts file. Mozilla has explicitly marked this as "wontfix". You then keep seeing the old site and consider the test failed, even though the new server has been answering correctly all along. So disable encrypted DNS resolution in the test browser, or better still: verify independently of any browser. curl can override the resolution per call, entirely without a hosts file:
curl -sI --resolve example.com:443:203.0.113.10 https://example.com/
What you should go through in this state: home page, at least two subpages, a post with images, the login at /wp-login.php, the backend, a contact form, and with a shop a test item all the way to checkout. Images deserve particular attention, because missing uploads do not stand out in the backend.
What the hosts test explicitly does not cover: everything that reaches the server from outside. Payment provider webhooks, external cron jobs, search engine crawlers and your outgoing mail still end up at the old target. That is fine, you just have to be aware of it.
The cutover: delta sync and DNS
Between the first copy and the switch, comments, orders or posts have been added. The order for a clean cutover:
- Enable maintenance mode on the old server, or at least stop orders and comments briefly.
- Sync the files again, this time it takes seconds.
- Export the database freshly and import it on the new server.
- Flush the caches:
wp cache flushandwp rewrite flush --hard. - Check once more with curl against the new IP.
- Switch the A and AAAA records.
Do not forget the AAAA record. If it stays on the old IPv6 address, every visitor with IPv6 keeps landing on the old server while IPv4 visitors see the new site. That produces exactly the kind of fault where two people sit next to each other and see different websites.
Leave the old server running for at least a week, without changes. It is your way back and your archive.
How to tell that it really worked
Not "the site loads", but measurable evidence:
dig +short A example.com
dig +short AAAA example.com
curl -sI https://example.com/ | head -n 12
The header must not contain a 301 to an old domain or to http://. Then ask the database which address WordPress itself considers correct:
wp option get siteurl
wp option get home
A reliable test for leftovers you missed is a search run in dry mode. If it reports zero replacements, the old address really is not stored anywhere any more:
wp search-replace 'alte-domain.de' 'alte-domain.de' --all-tables --dry-run --report-changed-only
On top of that, check the certificate, including validity period and covered names:
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null | openssl x509 -noout -dates -subject
And finally the error log of the web server, while you click through the site for five minutes. If nothing new turns up there, you are done. If something does hang, fixing 502 Bad Gateway is the most common place to start, usually because the PHP-FPM socket in the nginx block still carries the path of the old PHP version.
If it goes wrong: the way back
The way back consists of a single step, and that is why step 1 mattered so much: point the A and AAAA records back at the old IP. With a TTL of 300 seconds, most visitors are back on the old server within five minutes, and that server has been running unchanged all along.
There is exactly one case in which this is not clean: when data has already been created on the new server that does not exist on the old one. New orders, comments, registrations. After a rollback, that data only exists in the new system. This is why you decide within the first few minutes or not at all. And this is why maintenance mode during the cutover matters: it keeps the window small in which the two data sets can drift apart.
If you do have to go back later, export only the affected tables from the new server and load them into the old one, instead of pushing the complete database back. A plain rollback from an old full backup deletes everything that happened in the meantime.
Error messages, verbatim
Error establishing a database connection
The credentials or DB_HOST in wp-config.php do not match the new server. Often the IP of an external database server is still in there instead of localhost.
ERROR 1273 (HY000): Unknown collation: 'utf8mb4_0900_ai_ci'
A dump from MySQL 8 is being imported into MariaDB. Set the collation in the dump to utf8mb4_unicode_ci with sed.
Error: This does not seem to be a WordPress installation.
WP-CLI was started in the wrong directory, or the files are not where you assume they are. Work with --path=/var/www/html.
ERR_TOO_MANY_REDIRECTS
Almost always a contradiction between siteurl in the database, a constant in wp-config.php and the redirect in the web server. It also affects installations behind a proxy that never learn the HTTPS status and therefore bounce endlessly from http to https and back.
Home page loads, all subpages return 404
The classic when moving from Apache to nginx. The .htaccess with the permalink rules is ignored by nginx, which instead needs try_files $uri $uri/ /index.php?$args; in the location block.
The uploaded file exceeds the upload_max_filesize directive in php.ini.
The PHP configuration was not migrated along. Set upload_max_filesize, post_max_size and memory_limit to the values of the old server.
Blank page, no entry in the log
Usually a drop-in that points nowhere: wp-content/object-cache.php or advanced-cache.php. Rename it and reload.
A migration that runs like this has no window without a website. The only noticeable moment is the DNS change, and you determined its length yourself two days earlier.
Frequently asked questions
How long does it take until the DNS switch has taken effect everywhere?
Why can I not simply replace the domain in the database with an SQL statement?
Do I have to replace the domain at all if only the server changes?
How do I get a Let's Encrypt certificate while the domain still points at the old server?
I adjusted the hosts file but still see the old site. What is causing that?
What is the fastest way back if something does not work after the switch?
2026 KernelHost GmbH. All rights reserved. This guide is protected by copyright. Republishing it on other websites, in whole, in part or in edited form, is not permitted without our written consent. Quoting with a source credit and a link is expressly welcome.

