Migrar o WordPress para um novo servidor sem downtime

Publicado a 17 min de leitura

A ordem é que decide: preparar o novo servidor, copiar os dados, testar sob o domínio real através do ficheiro hosts, emitir o certificado com antecedência e só depois mudar o DNS. Com caminho de regresso e as mensagens de erro reais.

Uma migração de WordPress raramente falha por causa de um único comando. Falha por causa da ordem das operações. Quem muda primeiro o DNS e só depois copia os dados garante uma janela em que os visitantes veem uma página de erro ou uma instalação meio preenchida. Quem faz ao contrário consegue terminar o novo servidor com calma, testá-lo sob o domínio real e alterar o registo DNS apenas no momento em que tudo funciona de forma comprovada.

Este artigo descreve exatamente essa ordem e também os pontos onde a coisa rebenta na prática: dados serializados na base de dados, collations na passagem de MySQL para MariaDB, certificados sem um registo DNS a apontar para o sítio certo e o caminho de regresso, caso algo tenha escapado.

O procedimento que não gera qualquer interrupção

O site antigo permanece online até ao último momento. Nada é desligado, nada é apagado. O novo servidor corre em paralelo, é testado e só assume quando o registo DNS estiver alterado.

  1. Baixar o TTL dos registos A e AAAA para 300 segundos, no mínimo 24 a 48 horas antes.
  2. Preparar o novo servidor: servidor web, PHP, base de dados, utilizadores, diretórios.
  3. Copiar os ficheiros, copiar a base de dados.
  4. Emitir o certificado para o domínio, mesmo que o DNS ainda aponte para o servidor antigo.
  5. Adaptar o ficheiro hosts no seu próprio computador e percorrer o site sob o domínio real.
  6. Pouco antes da mudança, executar uma sincronização diferencial para trazer as últimas alterações.
  7. Alterar o DNS. Deixar o servidor antigo a trabalhar mais alguns dias.

O único ponto em que teoricamente pode surgir uma janela de indisponibilidade é o passo 6. O tamanho dessa janela decide-se logo no passo 1.

Baixar o TTL antes de qualquer outra coisa

O TTL indica aos resolvedores de todo o mundo durante quanto tempo podem guardar uma resposta em cache. Se estiver em 86400, um resolvedor ainda pode entregar o seu IP antigo durante 24 horas depois de ter feito a mudança. E há um pormenor que passa facilmente despercebido: um TTL reduzido só produz efeito depois de o TTL antigo ter expirado. Se passar de 86400 para 300, pode demorar um dia até que todos os resolvedores conheçam o valor curto.

Por isso, este é o primeiro gesto e não o último. O dig não faz parte da instalação base de nenhuma distribuição e é usado ao longo de todo este guia, por isso comece por instalá-lo:

apt install -y bind9-dnsutils

No Debian 11, no Ubuntu 22.04 e no Ubuntu 24.04, o pacote ainda se chama dnsutils. A partir do Debian 12, dnsutils é apenas um pacote de transição que remete para bind9-dnsutils e, no Debian 13, é mesmo um pacote puramente virtual, sem versão própria. Ambos funcionam, porque o apt resolve sozinho o único fornecedor, mas bind9-dnsutils é o nome que fica. Depois, verifique o valor atual:

dig +noall +answer example.com A
dig +noall +answer example.com SOA

O número na segunda coluna da resposta A é o TTL que ainda falta decorrer. Pergunte diretamente ao servidor autoritativo, caso contrário só vê o valor residual guardado na cache do seu resolvedor:

dig @a.ns14.net example.com A +noall +answer

Depois da migração, volte a subir o TTL para 3600 ou mais. TTLs permanentemente curtos geram carga desnecessária e prolongam as falhas do seu fornecedor de DNS.

Equipar o novo servidor com o que é preciso

É aqui que nascem as piores surpresas, porque a distribuição do novo servidor entrega versões diferentes das da antiga. Todos os comandos de pacotes deste guia partem do princípio de que usa Debian ou Ubuntu; no AlmaLinux, no Rocky Linux e no Oracle Linux não existe apt e os nomes dos pacotes são diferentes. Em julho de 2026, o panorama é o seguinte:

SistemaPHPBase de dadosnginx
Debian 138.4MariaDB 11.8 (sem mysql-server)1.26.3
Debian 128.2MariaDB 10.111.22.1
Ubuntu 24.048.3MySQL 8.0.46 ou MariaDB 10.111.24.0
Ubuntu 22.048.1MySQL 8.0.46 ou MariaDB 10.61.18.0

Daí resultam duas consequências. Primeira: no Debian não recebe nenhum mysql-server, ali corre sempre o MariaDB. Se o site antigo funcionava sobre MySQL 8, a migração para Debian é ao mesmo tempo uma mudança de base de dados, com uma armadilha muito concreta descrita mais abaixo. Segunda: o salto de PHP 8.1 para 8.4 não acontece por si só. Temas e plugins mais antigos reagem às funções removidas com um Fatal error: Uncaught Error: Call to undefined function ou com uma página em branco. Se a instalação antiga corria em PHP 8.1 e não quiser juntar a migração a uma atualização de PHP, o Ubuntu 22.04 ou uma versão de PHP vinda de um repositório externo é a escolha mais tranquila. O que falta propositadamente na tabela é o Debian 11: aí o php-cli ainda entrega o PHP 7.4.33, ou seja, uma versão sem manutenção de segurança e abaixo do que o WordPress recomenda. Como destino de uma migração, o Debian 11 fica assim excluído, a não ser que integre previamente o repositório Sury.

Os pacotes base para uma instalação típica com nginx e 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

Os detalhes sobre o servidor web estão no guia do nginx e a proteção base da base de dados em Proteger o MariaDB e o MySQL. Se o servidor for acabado de instalar, vale a pena espreitar antes a checklist para novos servidores root.

Crie a base de dados e o utilizador com o mesmo conjunto de carateres da origem:

CREATE DATABASE wp_novo CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'wp_novo'@'localhost' IDENTIFIED BY 'uma-palavra-passe-longa';
GRANT ALL PRIVILEGES ON wp_novo.* TO 'wp_novo'@'localhost';
FLUSH PRIVILEGES;

Transferir os ficheiros sem destruir as permissões

A cópia faz-se diretamente de servidor para servidor, não através da sua ligação doméstica. O mais simples é usar rsync do servidor antigo para o novo, com uma chave SSH em vez de palavra-passe:

rsync -az --delete --exclude 'wp-content/cache/' --exclude 'wp-content/uploads/backup*' \
  -e 'ssh -p 22' /var/www/html/ root@novo.ip:/var/www/html/

A primeira passagem pode demorar. É precisamente por isso que se faz dias antes da mudança e que, pouco antes dela, se repete apenas o mesmo comando, que nessa altura só transfere as diferenças.

Depois da cópia há que acertar o proprietário e as permissões, porque os UID diferem entre sistemas e o rsync transfere números, não nomes:

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

Dois ficheiros saem antes do primeiro arranque, caso existam: wp-content/object-cache.php e wp-content/advanced-cache.php. Ambos são drop-ins de plugins de cache que apontam para um socket Redis ou Memcached que ainda não existe no novo servidor. O resultado seria uma página em branco, sem qualquer mensagem aproveitável.

Transferir a base de dados e a armadilha das collations

O dump faz-se com o WP-CLI ou pela via clássica. O WP-CLI tem a vantagem de ir buscar o conjunto de carateres e o prefixo ao wp-config.php:

cd /var/www/html
wp db export /root/wp-dump.sql --add-drop-table

Sem o WP-CLI, num sistema MariaDB atual a ferramenta chama-se mariadb-dump. O nome antigo mysqldump é, a partir do MariaDB 11.0, apenas uma ligação simbólica obsoleta e limita-se a responder com Deprecated program name. It will be removed in a future release:

mariadb-dump --single-transaction --default-character-set=utf8mb4 wp_antigo > /root/wp-dump.sql

Chegamos agora ao ponto em que uma migração do Ubuntu com MySQL 8 para Debian com MariaDB cai regularmente de cara no chão. O MySQL 8 usa por omissão a collation utf8mb4_0900_ai_ci, que simplesmente não existe no MariaDB. A importação interrompe-se com:

ERROR 1273 (HY000) at line 42: Unknown collation: 'utf8mb4_0900_ai_ci'

Correção antes da importação, diretamente no 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

Depois, importe:

mariadb --default-character-set=utf8mb4 wp_novo < /root/wp-dump.sql

Se depois da importação aparecer por todo o lado função em vez de função, o conjunto de carateres perdeu-se no dump ou na importação. Isso não se corrige com uma pesquisa e substituição, mas repetindo o dump e a importação com --default-character-set=utf8mb4. É por isso que a base de dados antiga continua intacta nesta fase.

A seguir, ajuste os dados de acesso no wp-config.php do novo servidor: DB_NAME, DB_USER, DB_PASSWORD e, muito importante, DB_HOST, caso a instalação antiga apontasse para um servidor de base de dados remoto. Se o endereço antigo ficar lá, aparece Error establishing a database connection apesar de tudo funcionar localmente. Se a ligação emperrar apesar de os dados estarem corretos, ajuda o artigo Resolver Access denied for user.

Substituir o domínio, e porque é que um REPLACE em SQL desfaz o site

Primeiro a boa notícia: se mudar apenas o servidor e o domínio continuar o mesmo, não precisa de substituir absolutamente nada na base de dados. Essa é justamente uma das razões pelas quais o teste através do ficheiro hosts é superior ao teste através de um domínio provisório. A substituição só se torna necessária se o domínio mudar mesmo, se juntar à migração a passagem de http para https ou se, ainda assim, trabalhar com um domínio de teste e tiver de reverter a substituição no fim.

E agora a razão pela qual isto nunca se faz com um simples comando SQL. O WordPress guarda opções, definições de widgets e configurações de temas como arrays PHP serializados. Um URL não aparece ali nu, mas precedido do seu comprimento em bytes:

s:19:"https://example.com"

Se substituir example.com por dominio-novo.pt através de UPDATE ... REPLACE(), a string fica mais comprida, mas o número 19 mantém-se. Depois disso o PHP já não consegue desempacotar o array, o unserialize() devolve false e a definição afetada desaparece. Sintomas típicos: widgets desaparecidos, opções do tema repostas, entradas de menu vazias e, nos logs, Notice: unserialize(): Error at offset.

O WP-CLI resolve isto corretamente, porque desempacota os dados, faz a substituição e volta a escrevê-los com o comprimento corrigido. Sempre primeiro com --dry-run:

wp search-replace 'https://dominio-antigo.pt' 'https://dominio-novo.pt' --all-tables --precise --dry-run --report-changed-only

O que fazem as várias opções: --all-tables mexe também nas tabelas que não seguem o prefixo do WordPress, por exemplo as de plugins de loja ou de formulários. --precise força o processamento em PHP em vez de SQL, é mais lento, mas trata os dados serializados de forma fiável. --report-changed-only reduz o resultado às tabelas com correspondências reais.

Se a pré-visualização parecer plausível, execute o mesmo comando sem --dry-run. A seguir vem a passagem que quase todos os guias esquecem: os page builders como o Elementor guardam os seus conteúdos em JSON na base de dados e ali as barras estão escapadas. Uma substituição de https://dominio-antigo.pt simplesmente não encontra https:\/\/dominio-antigo.pt. Daí a necessidade de uma segunda passagem:

wp search-replace 'https:\/\/dominio-antigo.pt' 'https:\/\/dominio-novo.pt' --all-tables --precise --report-changed-only

No caso do Elementor, é preciso ainda regenerar os ficheiros CSS no backend, em Ferramentas, caso contrário as folhas de estilo geradas continuam a apontar para o domínio antigo.

Duas coisas que o search-replace não alcança: as constantes no wp-config.php e o multisite. Se ali estiver define('WP_HOME', 'https://dominio-antigo.pt');, isso sobrepõe-se a qualquer valor da base de dados e passa horas a procurar no sítio errado. Em multisite precisa ainda de --network e tem de ajustar os domínios em wp_blogs e wp_site.

E um aviso sobre o prefixo das tabelas: não o altere durante a migração. O prefixo está também no nome da opção wp_user_roles e nos campos de meta do utilizador wp_capabilities e wp_user_level. Quem se limita a renomear as tabelas até consegue iniciar sessão, mas recebe logo a seguir Sorry, you are not allowed to access this page. e fica sem administrador.

Emitir o certificado antes de o DNS apontar

Aqui há um problema do ovo e da galinha. A habitual validação HTTP da Let's Encrypt exige que o nome de domínio já aponte para o servidor que vai ser validado. Nesta fase ainda não aponta, porque deve continuar a servir o site antigo. Quem mesmo assim executar certbot --nginx recebe:

Certbot failed to authenticate some domains (authenticator: nginx).
Invalid response from http://example.com/.well-known/acme-challenge/...: 404

A saída limpa é a validação por DNS. Essa verifica um registo TXT _acme-challenge.example.com e não se interessa por saber para onde aponta o registo A:

certbot certonly --manual --preferred-challenges dns -d example.com -d www.example.com

O Certbot mostra um valor que deve colocar como registo TXT na sua zona DNS. Antes de confirmar, verifique por si próprio se a zona já entrega o registo, caso contrário queima uma tentativa:

dig +short TXT _acme-challenge.example.com

Para domínios wildcard, ou se quiser automatizar o processo, o caminho através da API de DNS está descrito no artigo sobre o certificado wildcard. A variante manual não se renova sozinha, por isso mude para a validação HTTP normal depois da mudança de DNS. A partir daí ela funciona, porque o domínio já aponta para o novo servidor.

Testar com o ficheiro hosts, como um visitante real

Agora vem a parte que faz a diferença entre "devia funcionar" e "funciona". Encaminha apenas o seu próprio computador para o novo servidor, enquanto o resto do mundo continua a ver o site antigo.

Em Linux e macOS edita o /etc/hosts como root, em Windows o C:\Windows\System32\drivers\etc\hosts num editor iniciado como administrador. Acrescente uma linha com o IP do novo servidor:

203.0.113.10 example.com www.example.com

Depois limpe a cache de DNS, caso contrário a alteração não produz efeito imediato:

resolvectl flush-caches

Em Windows ipconfig /flushdns, em macOS sudo dscacheutil -flushcache seguido de sudo killall -HUP mDNSResponder. Confirme se resultou:

getent hosts example.com

Uma armadilha que custa muitas horas: o Firefox com DNS over HTTPS ativado ignora o ficheiro hosts. A Mozilla marcou isso expressamente como "wontfix". Continua então a ver o site antigo e dá o teste por falhado, apesar de o novo servidor já responder corretamente há muito. Por isso, desative a resolução de DNS cifrada no navegador de teste ou, melhor ainda, verifique de forma independente do navegador. O curl consegue sobrepor a resolução em cada chamada, sem qualquer ficheiro hosts:

curl -sI --resolve example.com:443:203.0.113.10 https://example.com/

O que deve percorrer neste estado: a página inicial, pelo menos duas páginas internas, um artigo com imagens, o início de sessão em /wp-login.php, o backend, um formulário de contacto e, no caso de uma loja, um artigo de teste até à finalização da compra. As imagens merecem atenção especial, porque os carregamentos em falta não dão nas vistas no backend.

O que o teste com o ficheiro hosts expressamente não cobre: tudo o que acede ao servidor a partir do exterior. Os webhooks dos fornecedores de pagamento, os cronjobs externos, os crawlers dos motores de busca e o seu envio de e-mail continuam a aterrar no destino antigo. Não faz mal, só precisa de o saber.

A mudança: sincronização diferencial e DNS

Entre a primeira cópia e a mudança acumularam-se comentários, encomendas ou artigos. A ordem para uma mudança limpa:

  1. Ativar o modo de manutenção no servidor antigo ou, pelo menos, parar por instantes as encomendas e os comentários.
  2. Sincronizar novamente os ficheiros, desta vez demora segundos.
  3. Exportar a base de dados de novo e importá-la no novo servidor.
  4. Limpar as caches: wp cache flush e wp rewrite flush --hard.
  5. Verificar mais uma vez com curl contra o novo IP.
  6. Alterar os registos A e AAAA.

Não se esqueça do registo AAAA. Se ficar no endereço IPv6 antigo, todos os visitantes com IPv6 continuam a aterrar no servidor antigo, enquanto os visitantes em IPv4 veem o site novo. Isso gera exatamente o tipo de sintoma em que duas pessoas sentadas lado a lado veem sites diferentes.

Deixe o servidor antigo a trabalhar durante pelo menos uma semana, sem lhe tocar. É o seu caminho de regresso e o seu arquivo.

Como saber que a migração correu mesmo bem

Não basta "o site carrega", são precisas provas mensuráveis:

dig +short A example.com
dig +short AAAA example.com
curl -sI https://example.com/ | head -n 12

Nos cabeçalhos não pode aparecer nenhum 301 para um domínio antigo ou para http://. Depois pergunte à base de dados qual é o endereço que o próprio WordPress considera correto:

wp option get siteurl
wp option get home

Um teste fiável para restos esquecidos é uma pesquisa em modo seco. Se comunicar zero substituições, o endereço antigo já não está mesmo registado em lado nenhum:

wp search-replace 'dominio-antigo.pt' 'dominio-antigo.pt' --all-tables --dry-run --report-changed-only

Controle também o certificado, incluindo a validade e os nomes abrangidos:

openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null | openssl x509 -noout -dates -subject

E, por fim, o log de erros do servidor web, enquanto navega cinco minutos pelo site. Se ali não aparecer nada de novo, está feito. Se ainda assim algo emperrar, Resolver o erro 502 Bad Gateway é o ponto de partida mais frequente, normalmente porque o socket PHP-FPM no bloco do nginx ainda traz o caminho da versão antiga do PHP.

Se algo correr mal: o caminho de regresso

O caminho de regresso resume-se a um único gesto e é por isso que o passo 1 era tão importante: repor os registos A e AAAA no IP antigo. Com um TTL de 300 segundos, a maioria dos visitantes está de volta ao servidor antigo em menos de cinco minutos, servidor esse que continuou a trabalhar sem alterações.

Existe exatamente um caso em que isto não é limpo: quando no novo servidor já surgiram dados que não existem no antigo. Novas encomendas, comentários, registos de utilizadores. Depois de um rollback, esses dados só existem no sistema novo. Por isso decide-se nos primeiros minutos ou não se decide de todo. E é também por isso que existe o modo de manutenção durante a mudança: mantém pequena a janela em que os dois conjuntos de dados se podem afastar um do outro.

Se mesmo assim tiver de voltar atrás mais tarde, exporte do novo servidor apenas as tabelas afetadas e carregue-as no antigo, em vez de empurrar de volta a base de dados completa. Um rollback puro a partir de uma cópia de segurança antiga apaga tudo o que aconteceu entretanto.

As mensagens de erro, à letra

Error establishing a database connection
Os dados de acesso ou o DB_HOST no wp-config.php não correspondem ao novo servidor. É frequente estar lá ainda o IP de um servidor de base de dados externo em vez de localhost.

ERROR 1273 (HY000): Unknown collation: 'utf8mb4_0900_ai_ci'
Está a importar em MariaDB um dump vindo do MySQL 8. Altere a collation no dump para utf8mb4_unicode_ci com sed.

Error: This does not seem to be a WordPress installation.
O WP-CLI foi iniciado no diretório errado ou os ficheiros não estão onde supõe. Trabalhe com --path=/var/www/html.

ERR_TOO_MANY_REDIRECTS
Quase sempre uma contradição entre o siteurl na base de dados, uma constante no wp-config.php e o redirecionamento no servidor web. Além disso, afeta instalações atrás de um proxy que não percebem o estado HTTPS e por isso enviam sem fim de http para https e de volta.

A página inicial carrega, todas as páginas internas devolvem 404
O clássico da passagem de Apache para nginx. O .htaccess com as regras das ligações permanentes é ignorado pelo nginx; ali é preciso try_files $uri $uri/ /index.php?$args; no bloco location.

O ficheiro carregado excede a diretiva upload_max_filesize no php.ini.
A configuração de PHP não foi migrada com o resto. Coloque upload_max_filesize, post_max_size e memory_limit nos valores do servidor antigo.

Página em branco, nenhuma entrada no log
Na maioria dos casos um drop-in que aponta para o vazio: wp-content/object-cache.php ou advanced-cache.php. Mude-lhe o nome e recarregue.

Uma migração conduzida assim não tem qualquer janela sem site. O único momento percetível é a mudança de DNS e a duração desse momento foi definida por si dois dias antes.

Perguntas frequentes

Quanto tempo demora até a mudança de DNS produzir efeito em todo o lado?
Isso depende exclusivamente do TTL que estava em vigor antes da mudança. Com 300 segundos, praticamente todos os resolvedores mudam dentro de cinco a dez minutos. Com 86400 pode demorar um dia inteiro. Como um TTL reduzido só produz efeito depois de o antigo expirar, baixe-o 24 a 48 horas antes da migração.
Porque não posso substituir o domínio na base de dados com um simples comando SQL?
O WordPress guarda muitas definições como arrays PHP serializados, nos quais cada texto é precedido do seu comprimento em bytes, por exemplo s:19:"https://example.com". Um REPLACE() altera o texto, mas não o valor do comprimento. Depois disso o PHP já não consegue desempacotar o array e a definição fica perdida. O WP-CLI desempacota, substitui e volta a serializar, e é por isso que wp search-replace é o caminho certo.
Tenho sequer de substituir o domínio se apenas o servidor muda?
Não. Se o domínio se mantiver igual, nada muda na base de dados. A substituição só se torna necessária numa mudança real de domínio, na passagem simultânea de http para https ou se tiver trabalhado através de um domínio de teste e tiver de reverter a substituição no fim.
Como obtenho um certificado Let's Encrypt enquanto o domínio ainda aponta para o servidor antigo?
Através da validação por DNS em vez da validação HTTP: certbot certonly --manual --preferred-challenges dns -d example.com. O que é verificado é um registo TXT em _acme-challenge e o registo A não desempenha aí qualquer papel. Depois da mudança de DNS, passe para a validação HTTP automática, para que a renovação corra sem trabalho manual.
Alterei o ficheiro hosts, mas continuo a ver o site antigo. A que se deve?
Ou a cache de DNS do sistema operativo ainda está quente, e nesse caso ajuda ipconfig /flushdns, resolvectl flush-caches ou dscacheutil -flushcache. Ou o próprio navegador resolve através de DNS over HTTPS. O Firefox ignora comprovadamente o ficheiro hosts nesse modo. A verificação fiável é com curl e --resolve, que contorna o navegador por completo.
Qual é o caminho de regresso mais rápido se algo não funcionar depois da mudança?
Repor os registos A e AAAA no IP antigo. O servidor antigo continua na mesma a trabalhar sem alterações. Com um TTL curto, a maioria dos visitantes volta a estar lá em poucos minutos. O senão: os dados que entretanto surgiram apenas no novo servidor, por exemplo encomendas, ficam a faltar no antigo. Por isso mantém-se a janela pequena com um breve modo de manutenção e decide-se cedo.

WordPress Migração de servidores DNS WP-CLI MariaDB Let's Encrypt VPS