tva
← Artigos

Recuperação de desastres para serviços auto-hospedados: nossa estratégia de backup

A auto-hospedagem oferece um controle que os serviços gerenciados não oferecem: você escolhe o hardware, a rede, a residência dos dados e a estrutura de custos. Mas na realidade, esse controle vem com uma responsabilidade que os serviços gerenciados assumem sem que você perceba: quando algo dá errado, a responsabilidade é inteiramente sua. Para uma configuração de servidor único executando múltiplos serviços de produção — Supabase, um CRM, um DAM, um servidor Git — a estratégia de backup e recuperação não é uma preocupação secundária. É a diferença entre um incidente recuperável e uma perda de dados permanente.

Esta publicação documenta a estratégia de backup que utilizamos para um ambiente auto-hospedado de produção. Os serviços envolvidos são uma stack Supabase (PostgreSQL como repositório de dados primário), um CRM auto-hospedado (Twenty, que também executa PostgreSQL), um servidor Git Gitea e um sistema de gestão de ativos digitais. Todos eles são executados como contêineres Docker em um único VPS da Hetzner. A estratégia precisa contemplar todos eles sem criar períodos de carga inaceitáveis ou dependências complexas de orquestração.

Por que um único servidor aumenta os riscos

Uma configuração de servidor único não tem redundância integrada. Não há réplica de espera, nenhum comutação em caso de falha de múltiplas zonas de disponibilidade, nenhuma restauração automática de banco de dados acionada por uma verificação de saúde. Quando o servidor falha — seja por falha de hardware, uma atualização mal feita, um problema de armazenamento ou um incidente de segurança — o único caminho de recuperação é um backup. Se o backup estiver incompleto, desatualizado ou não testado, as possibilidades de recuperação serão limitadas na mesma medida.

Esse é um modelo de ameaça diferente de um serviço de nuvem gerenciado, onde a falha de hardware é em grande parte invisível porque o provedor de infraestrutura lida com o comutação em caso de falha. Em um único VPS, você planeja explicitamente para os modos de falha que os serviços gerenciados abstraem. O planejamento não é particularmente complexo, mas requer fazê-lo antes do incidente, não durante.

O que precisa de backup

Cada serviço tem seu próprio perfil de dados. Entender quais dados são insubstituíveis e onde estão é o pré-requisito para projetar a estratégia de backup.

O Supabase armazena seus dados em uma instância PostgreSQL gerenciada pela stack do Supabase. O banco de dados contém todos os dados da aplicação, registros de usuários e os próprios metadados de autenticação e armazenamento do Supabase. Os buckets de armazenamento (arquivos enviados) são armazenados em disco e precisam de tratamento separado do backup do banco de dados.

O Twenty CRM é suportado por sua própria instância PostgreSQL, separada do Supabase. Contém registros de contatos, dados de oportunidades e estado de fluxos de trabalho. Esse banco de dados é menor em volume, mas altamente sensível — perder dados do CRM é mais prejudicial à operação do que perder dados de aplicação que podem ser recriados.

O Gitea armazena repositórios em disco como objetos Git bare. Os dados do repositório são em princípio reconstruíveis a partir das estações de trabalho dos desenvolvedores (já que um clone completo, não superficial, contém o histórico que buscou, mas não os objetos Git LFS nem as referências que nunca buscou), mas os dados do sistema de acompanhamento de issues, comentários de pull requests e configuração de equipe existem apenas no banco de dados do Gitea e não estão presentes em nenhum clone. Tanto os objetos Git quanto o banco de dados do Gitea precisam de backup.

O DAM (gestão de ativos digitais) armazena arquivos originais, derivados processados e metadados. Os arquivos originais são a parte insubstituível; os derivados podem ser regenerados. Os metadados ficam em um banco de dados que registra relacionamentos de arquivos, tags e direitos de uso.

Orquestração do pg_dump

O pg_dump do PostgreSQL é a ferramenta certa para backups lógicos de banco de dados. Ele exporta uma representação SQL do banco de dados que pode ser restaurada para qualquer versão compatível do PostgreSQL, o que é mais portátil do que backups físicos (que requerem a mesma versão do PostgreSQL e layout binário).

O script de backup para cada instância PostgreSQL segue o mesmo padrão:

#!/usr/bin/env bash
set -euo pipefail

TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DB_NAME="$1"
CONTAINER_NAME="$2"
BACKUP_DIR="/opt/backups/postgres"
OUTPUT_FILE="${BACKUP_DIR}/${DB_NAME}_${TIMESTAMP}.sql.gz"

mkdir -p "${BACKUP_DIR}"

docker exec "${CONTAINER_NAME}"   pg_dump -U postgres -d "${DB_NAME}"   | gzip -9 > "${OUTPUT_FILE}"

echo "Backup complete: ${OUTPUT_FILE} ($(du -sh "${OUTPUT_FILE}" | cut -f1))"

O set -euo pipefail no início é importante: faz o script sair imediatamente se qualquer comando falhar, incluindo comandos em um pipeline. Sem pipefail, um pg_dump com falha seguido de um gzip bem-sucedido deixaria um arquivo compactado vazio ou truncado (a mensagem de erro vai para stderr, não para o arquivo), enquanto o script ainda indicaria sucesso; o problema só apareceria na restauração.

A saída é compactada com gzip -9 inline em vez de uma etapa de pós-processamento. Isso mantém o uso de disco reduzido e evita escrever um dump não compactado em disco primeiro, o que importa em servidores onde o espaço livre é uma restrição gerenciada.

Agendamento escalonado

Executar todas as tarefas de backup simultaneamente criaria uma janela de contenção de I/O que poderia degradar os serviços sendo copiados. Uma instância Supabase sob carga não se beneficia de competir com um pg_dump pelo I/O de disco. A solução é o agendamento escalonado — cada tarefa de backup começa em um horário diferente, com espaçamento suficiente para permitir que a tarefa anterior seja concluído antes que o próximo comece.

O agendamento que utilizamos, expresso coma entradas cron:

# Backup do PostgreSQL do Supabase — 02:00 diariamente
0 2 * * * /opt/scripts/pg-backup.sh supabase supabase-db

# Backup do PostgreSQL do Twenty CRM — 02:30 diariamente
30 2 * * * /opt/scripts/pg-backup.sh twenty twenty-db

# Backup do banco de dados do Gitea — 03:00 diariamente
0 3 * * * /opt/scripts/pg-backup.sh gitea gitea-db

# Backup do banco de dados do DAM — 03:30 diariamente
30 3 * * * /opt/scripts/pg-backup.sh dam dam-db

# Objetos de repositório do Gitea — 04:00 diariamente
0 4 * * * /opt/scripts/git-objects-backup.sh

# Arquivos de armazenamento (Supabase + originais do DAM) — 04:30 diariamente
30 4 * * * /opt/scripts/files-backup.sh

Os intervalos de 30 minutos entre as tarefas são conservadores — a maioria dos backups de banco de dados é concluída em poucos minutos para bancos de dados desse tamanho. Mas os intervalos também consideram a etapa de upload que segue cada backup: o arquivo compactado é enviado para o armazenamento de objetos antes que a próxima tarefa comece, de modo que o disco local não está acumulando vários dias de backups simultaneamente.

Upload para armazenamento de objetos e retenção

Backups que existem apenas no mesmo servidor que os serviços que protegem não são backups — são snapshots que serão perdidos no mesmo incidente que destrói os dados que protegem. Todo backup deve ser enviado para um local de armazenamento separado antes que a cópia local possa ser considerada completa.

Usamos um provedor de armazenamento de objetos compatível com S3 (separado do provedor de VPS) e fazemos upload com rclone, que lida automaticamente com novas tentativas e verificação de checksums após a transferência:

rclone copy "${OUTPUT_FILE}" "backup-remote:tva-backups/postgres/${DB_NAME}/"   --checksum   --transfers 1   --log-level INFO

O sinalizador --checksum faz o rclone comparar arquivos por hash e tamanho (MD5 em destinos remotos compatíveis com S3), em vez de hora de modificação e tamanho. Junto com a verificação de hash que o rclone realiza por padrão após a transferência, isso detecta corrupção durante a transferência quando o destino remoto armazena um hash utilizável.

A retenção é aplicada por uma tarefa de limpeza separada que é executada semanalmente. A política de retenção é: backups diários retidos por sete dias, backups semanais (o backup diário de domingo, renomeado por uma tarefa semanal) retidos por quatro semanas, backups mensais (do primeiro domingo de cada mês) retidos por seis meses. Isso fornece uma janela razoável para detectar perda de dados que não é imediatamente óbvia — uma linha corrompida introduzida há três semanas ainda pode ser recuperada de um backup semanal.

A limpeza de retenção usa rclone delete com um filtro na hora de modificação (--min-age) em vez de depender apenas dos nomes dos arquivos, o que é mais confiável quando as convenções de nomenclatura de arquivo não são perfeitamente consistentes:

rclone delete "backup-remote:tva-backups/postgres/"   --min-age 7d   --include "*_daily_*"   --dry-run

O sinalizador --dry-run é usado durante o teste da tarefa de limpeza. Remova-o somente após confirmar que os padrões de filtro correspondem exatamente ao que deve ser excluído. O padrão de --include pressupõe que os backups diários tenham o marcador _daily_ no nome; o script de backup acima nomeia os arquivos como DB_TIMESTAMP.sql.gz, portanto é preciso primeiro ajustar o padrão ou os nomes dos arquivos.

Testes de restauração

Um backup que nunca foi testado é uma hipótese. A única maneira de saber que um backup é restaurável é restaurá-lo. Realizamos um teste de restauração mensal para cada banco de dados, usando um contêiner Docker temporário isolado da stack de produção:

#!/usr/bin/env bash
set -euo pipefail

BACKUP_FILE="$1"
ROLES_FILE="${2:-}"
TEST_CONTAINER="restore-test-$(date +%s)"

# Iniciar um contêiner PostgreSQL temporário
docker run -d   --name "${TEST_CONTAINER}"   -e POSTGRES_PASSWORD=testpass   -e POSTGRES_DB=testdb   postgres:15-alpine

# Remover o contêiner ao sair, mesmo se uma etapa falhar
trap 'docker rm -f "${TEST_CONTAINER}" > /dev/null' EXIT

# Aguardar o PostgreSQL estar pronto
for _ in $(seq 1 30); do
  docker exec "${TEST_CONTAINER}" pg_isready -h 127.0.0.1 -U postgres -q && break
  sleep 1
done

# pg_dump não inclui roles: restaure-os primeiro a partir de um arquivo pg_dumpall --roles-only (2º argumento opcional)
if [ -n "${ROLES_FILE}" ]; then
  docker exec -i "${TEST_CONTAINER}"   psql -U postgres -d testdb -q < "${ROLES_FILE}"
fi

# Restaurar o backup
zcat "${BACKUP_FILE}" | docker exec -i "${TEST_CONTAINER}"   psql -U postgres -d testdb -v ON_ERROR_STOP=1

# Verificar se as contagens de linhas correspondem às expectativas
docker exec "${TEST_CONTAINER}"   psql -U postgres -d testdb   -c "SELECT schemaname, relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 10;"

A verificação de contagem de linhas não é exaustiva — verifica se as tabelas principais têm contagens estimadas de linhas plausíveis (n_live_tup é uma estimativa estatística, não uma contagem exata), não se cada linha está correta. Um teste mais completo executaria as próprias verificações de integridade da aplicação no banco de dados restaurado, mas a verificação de contagem de linhas detecta os modos de falha mais comuns: um backup truncado, uma restauração com falha que produziu um banco de dados vazio, ou uma incompatibilidade de versão que causou perda silenciosa de dados. A restauração roda com ON_ERROR_STOP=1, então a primeira instrução com falha faz o teste falhar, e o trap remove o contêiner em qualquer caso. O pg_dump não inclui roles: se o dump referencia roles além de postgres, passe como segundo argumento a saída de pg_dumpall --roles-only do servidor de origem (nessa etapa, um erro para a role postgres, que já existe, é esperado). A imagem de teste deve corresponder à versão principal do PostgreSQL da origem. O dump do Supabase também referencia schemas e extensões específicos do Supabase que a imagem postgres padrão não tem, por isso precisa de uma restauração de teste em uma instância Supabase, seguindo o procedimento de restauração do próprio Supabase.

Como é a recuperação real

A estratégia de backup é tão útil quanto o procedimento de recuperação que a utiliza. O procedimento operacional de recuperação — escrito como um documento Markdown no repositório de configuração do servidor — documenta a sequência exata de etapas para restaurar cada serviço em um novo VPS.

A sequência para uma falha completa do servidor é: provisionar um novo VPS, instalar Docker e a stack de aplicações a partir do repositório de configuração, baixar os arquivos de backup mais recentes do armazenamento de objetos, restaurar cada banco de dados na ordem de dependência (Supabase primeiro, depois CRM, depois os outros), restaurar o armazenamento de arquivos, atualizar o DNS e verificar cada serviço. O tempo estimado para uma recuperação completa é inferior a duas horas se todos os backups estiverem atualizados e o procedimento operacional for seguido sem improvisação.

A palavra-chave nessa estimativa é “seguido sem improvisação.” O procedimento operacional deve ser específico o suficiente para que uma pessoa que nunca tocou no sistema antes possa executá-lo com sucesso. Os comandos devem estar prontos para copiar e colar, não descritos em prosa. Cada etapa deve ter uma verificação de confirmação. A ambiguidade em um procedimento operacional de recuperação é um risco que aumenta sob o estresse de um incidente real.

Testamos o procedimento operacional anualmente realizando uma recuperação completa em um ambiente de staging. O teste detecta de forma confiável pelo menos uma etapa que mudou desde a última vez que o procedimento operacional foi escrito — uma versão de imagem Docker que mudou, uma chave de configuração que foi renomeada, uma variável de ambiente que foi adicionada. Detectar isso em um teste agendado é muito preferível a descobrir durante uma recuperação real.

Artigos relacionados

Leitura complementar