Este é um post-mortem/runbook de uma migração real. Pulo a preparação comum do Discourse (a documentação oficial cobre isso). Foco nas comutações exatas, nas armadilhas do Cloudflare R2, nos comandos únicos de rails/rake que importaram, no que falhou e em como fazer a mesma transição com baixo risco na próxima vez.
Estado final desejado
- O Discourse roda no novo host (Docker, único container
app). - Uploads + ativos de front-end residem no Cloudflare R2:
- Bucket
discourse-uploads(público) - Bucket
discourse-backups(privado)
- Bucket
- Domínio personalizado do R2:
https://files.example.com(criado em R2 → Domínios personalizados, não um CNAME manual entre contas).
0) Backups de banco de dados que realmente funcionam (diários e no momento da migração)
Backups diários são para recuperação de desastres. Um backup de última hora é para migração no momento da troca. Mantenha ambos.
0.1 Política
- Diários: backup apenas do BD (
.sql.gz, sem uploads) → verificar localmente → enviar para o R2. Mantenha ≥7 cópias (ou use o ciclo de vida do R2). - No momento da troca: logo antes da troca de DNS, faça outro backup apenas do BD e restaure-o no novo host para minimizar a lacuna de conteúdo.
0.2 Faça um backup apenas do BD e verifique
Dentro do container:
# Opcional, mas bom: reduzir gravações durante a criação do snapshot
discourse enable_readonly
# Dispare um backup apenas do BD pela UI de Admin (desmarque "com uploads")
# ou via CLI:
discourse backup
# Verifique o artefato
ls -lh /var/discourse/shared/standalone/backups/default/
zcat -t /var/discourse/shared/standalone/backups/default/<DB_ONLY>.sql.gz
Verificação profunda (melhor): restaure para um banco de dados temporário e conte as linhas:
cd /var/discourse && ./launcher enter app
sudo -E -u postgres psql -tc "DROP DATABASE IF EXISTS verifydb;"
sudo -E -u postgres createdb verifydb
zcat /shared/backups/default/<DB_ONLY>.sql.gz | sudo -E -u postgres psql verifydb
sudo -E -u postgres psql -d verifydb -c "select count(*) from topics where deleted_at is null;"
sudo -E -u postgres psql -d verifydb -c "select count(*) from posts where post_type=1 and deleted_at is null;"
sudo -E -u postgres dropdb verifydb
exit
Se o teste gzip ou a restauração temporária falhar, não envie esse arquivo para o R2—corrija e faça o backup novamente.
0.3 Envie para o R2 apenas após passar
aws s3 cp /var/discourse/shared/standalone/backups/default/<DB_ONLY>.sql.gz \
s3://discourse-backups/
0.4 Por que os tamanhos diferem (1–4 GB é normal)
Tanto o backup diário do Admin quanto o pg_dump manual produzem .sql.gz apenas do BD. As diferenças de tamanho geralmente vêm das tabelas incluídas e da compressão, não de “posts faltando”. Se quiser ver o que há dentro:
# Quais tabelas têm dados no dump?
zcat <DB_ONLY>.sql.gz | grep -E '^COPY public\.' | awk '{print $2}' | sort -u | head
# Aproximação rápida de contagem de linhas para tabelas-chave
zcat <DB_ONLY>.sql.gz | awk '/^COPY public.posts /{c=1;next}/^\\\./{c=0} c' | wc -l
zcat <DB_ONLY>.sql.gz | awk '/^COPY public.topics /{c=1;next}/^\\\./{c=0} c' | wc -l
Se essas contagens coincidirem com as expectativas, o backup contém todos os posts/tópicos, independentemente do tamanho do arquivo.
1) Host antigo: prepare e copie o backup (verificado) apenas do BD
Anuncie a manutenção → ative o modo somente leitura:
cd /var/discourse && ./launcher enter app
discourse enable_readonly
exit
Copie o .sql.gz verificado para o novo host:
rsync -avP -e "ssh -o StrictHostKeyChecking=no" \
root@OLD:/var/discourse/shared/standalone/backups/default/<DB_ONLY>.sql.gz \
/var/discourse/shared/standalone/backups/default/
Se quiser uma lacuna de conteúdo quase zero, repita esta etapa logo antes da troca de DNS.
2) Inicialização do novo host
Instale Docker + discourse_docker:
apt-get update && apt-get install -y git curl tzdata
curl -fsSL https://get.docker.com | sh
systemctl enable --now docker
git clone https://github.com/discourse/discourse_docker /var/discourse
Crie containers/app.yml com valores de produção. Mantenha os modelos de SSL comentados até que o DNS aponte para cá. Conjunto mínimo de env:
env:
DISCOURSE_HOSTNAME: forum.example.com
# R2 / S3
DISCOURSE_USE_S3: "true"
DISCOURSE_S3_REGION: "auto"
DISCOURSE_S3_ENDPOINT: "https://<ACCOUNT_ID>.r2.cloudflarestorage.com"
DISCOURSE_S3_FORCE_PATH_STYLE: "true"
DISCOURSE_S3_BUCKET: "discourse-uploads"
DISCOURSE_S3_BACKUP_BUCKET: "discourse-backups"
DISCOURSE_S3_ACCESS_KEY_ID: "<R2_KEY>"
DISCOURSE_S3_SECRET_ACCESS_KEY: "<R2_SECRET>"
DISCOURSE_S3_CDN_URL: "https://files.example.com"
DISCOURSE_BACKUP_LOCATION: "s3"
# Ajustes de checksum do R2 (evitar conflitos)
AWS_REQUEST_CHECKSUM_CALCULATION: "WHEN_REQUIRED"
AWS_RESPONSE_CHECKSUM_VALIDATION: "WHEN_REQUIRED"
# SMTP / E-mail do Let’s Encrypt
DISCOURSE_SMTP_ADDRESS: smtp.gmail.com
DISCOURSE_SMTP_PORT: 587
DISCOURSE_SMTP_USER_NAME: you@example.com
DISCOURSE_SMTP_PASSWORD: "<app-password>"
DISCOURSE_SMTP_DOMAIN: example.com
DISCOURSE_NOTIFICATION_EMAIL: you@example.com
LETSENCRYPT_ACCOUNT_EMAIL: you@example.com
Publique ativos no R2 durante a reconstrução:
hooks:
after_assets_precompile:
- exec:
cd: $home
cmd:
- sudo -E -u discourse bundle exec rake s3:upload_assets
- sudo -E -u discourse bundle exec rake s3:expire_missing_assets
Levante o container (apenas HTTP por enquanto):
cd /var/discourse && ./launcher rebuild app
3) Restaure o dump apenas do BD (.sql.gz via psql)
cd /var/discourse && ./launcher enter app
sv stop unicorn || true; sv stop sidekiq || true
# garanta um BD limpo
sudo -E -u postgres psql -c "REVOKE CONNECT ON DATABASE discourse FROM public;"
sudo -E -u postgres psql -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='discourse';"
sudo -E -u postgres psql -c "DROP DATABASE IF EXISTS discourse;"
sudo -E -u postgres psql -c "CREATE DATABASE discourse WITH OWNER discourse TEMPLATE template0 ENCODING 'UTF8';"
sudo -E -u postgres psql -d discourse -c "CREATE EXTENSION IF NOT EXISTS citext;"
sudo -E -u postgres psql -d discourse -c "CREATE EXTENSION IF NOT EXISTS hstore;"
# importe o dump
zcat /shared/backups/default/<DB_ONLY>.sql.gz | sudo -E -u postgres psql discourse
sv start unicorn
[ -d /etc/service/sidekiq ] && sv start sidekiq || true
exit
Se ainda estiver carregando uploads locais pré-R2, pode fazer um rsync deles uma vez como rede de segurança; migraremos eles para o R2 a seguir.
4) Ajustes do R2 que importaram
Buckets & token: crie discourse-uploads (público) e discourse-backups (privado). Inicialize com um Token de API da Conta escopado para esses dois buckets com Leitura e Escrita de Admin (para que PutBucketCors funcione), depois rotacione para Leitura e Escrita de Objeto após o sucesso.
Domínio personalizado: adicione files.example.com em R2 → Domínios personalizados na mesma conta Cloudflare que sua zona de DNS (evita erros de CNAME entre contas 1014).
CORS em discourse-uploads:
[
{
"AllowedOrigins": ["https://forum.example.com","https://files.example.com"],
"AllowedMethods": ["GET","HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["*"],
"MaxAgeSeconds": 86400
}
]
Reconstrua para que CSS/JS/fontes sejam publicados no R2:
cd /var/discourse && ./launcher rebuild app
5) Migração única de uploads históricos para o R2
cd /var/discourse && ./launcher enter app
yes "" | AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_REQUIRED AWS_RESPONSE_CHECKSUM_VALIDATION=WHEN_REQUIRED \
sudo -E -u discourse RAILS_ENV=production bundle exec rake uploads:migrate_to_s3
Se receber “X posts não remapeados…”, veja §7.2 para correções direcionadas.
6) Troca do domínio de produção
Defina em app.yml:
DISCOURSE_HOSTNAME: forum.example.com
LETSENCRYPT_ACCOUNT_EMAIL: you@example.com
DNS: aponte forum.example.com para o novo front (ou origem), ative os modelos de SSL, então:
cd /var/discourse && ./launcher rebuild app
Sanidade:
curl -I https://forum.example.com
./launcher logs app | tail -n 200
Ver HTTP/2 403 para anônimos geralmente significa login_required—não é uma queda.
7) Coisas que realmente quebraram (e correções)
7.1 Conflito de checksum do R2
Aws::S3::Errors::InvalidRequest: You can only specify one non-default checksum at a time.
Correção (mantenha permanentemente):
AWS_REQUEST_CHECKSUM_CALCULATION: "WHEN_REQUIRED"
AWS_RESPONSE_CHECKSUM_VALIDATION: "WHEN_REQUIRED"
7.2 “X posts não foram remapeados para a nova URL de upload S3”
Motivo: algum HTML cooked ainda aponta para /uploads/<db>/original/....
Reassamento direcionado:
sudo -E -u discourse RAILS_ENV=production bundle exec rails r '
db = RailsMultisite::ConnectionManagement.current_db
ids = Post.where("cooked LIKE ?", "%/uploads/#{db}/original%").pluck(:id)
ids.each { |pid| Post.find(pid).rebake! }
puts "rebaked=#{ids.size}"
'
Ou remapeie um prefixo estático e depois reassine os posts afetados:
sudo -E -u discourse RAILS_ENV=production bundle exec \
rake "posts:remap[/uploads/default/original,https://files.example.com/original]"
Rode a migração novamente para confirmar que está limpo:
yes "" | AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_REQUIRED AWS_RESPONSE_CHECKSUM_VALIDATION=WHEN_REQUIRED \
sudo -E -u discourse RAILS_ENV=production bundle exec rake uploads:migrate_to_s3
7.3 Tarefas “faltando”
Sempre execute com bundler + env:
sudo -E -u discourse RAILS_ENV=production bundle exec rake -T s3
sudo -E -u discourse RAILS_ENV=production bundle exec rake -T uploads
Imprima as configurações efetivas do S3:
sudo -E -u discourse RAILS_ENV=production bundle exec rails r \
'puts({ use_s3: ENV["DISCOURSE_USE_S3"], bucket: ENV["DISCOURSE_S3_BUCKET"], endpoint: ENV["DISCOURSE_S3_ENDPOINT"], cdn: ENV["DISCOURSE_S3_CDN_URL"] })'
7.4 s3:upload_assets AccessDenied
Use um token Admin RW para inicialização (operações de CORS no nível do bucket), depois rotacione para Objeto RW.
8) Verificação
Dentro do container
# URLs agora usando o CDN
sudo -E -u discourse RAILS_ENV=production bundle exec rails r \
'puts Upload.where("url LIKE ?", "%files.example.com%").limit(5).pluck(:url)'
# Referências restantes em cooked para uploads locais (deve tender a 0)
sudo -E -u discourse RAILS_ENV=production bundle exec rails r \
'db=RailsMultisite::ConnectionManagement.current_db; puts Post.where("cooked LIKE ?", "%/uploads/#{db}/original%").count'
Navegador
- Aba de Rede mostra ativos de
files.example.com. - Tópicos antigos mostram imagens sob
https://files.example.com/original/....
Backups
- Admin → Backups → crie um; confirme que um novo objeto aparece em
discourse-backupsno R2.
9) Limpeza
Quando as referências em cooked forem essencialmente 0:
mv /var/discourse/shared/standalone/uploads /var/discourse/shared/standalone/uploads.bak
mkdir -p /var/discourse/shared/standalone/uploads
chown -R 1000:1000 /var/discourse/shared/standalone/uploads
# após alguns dias estáveis
rm -rf /var/discourse/shared/standalone/uploads.bak
Rotacione segredos (token do R2 → Objeto RW; senha de app SMTP se alguma vez atingiu logs).
10) Próxima vez (manual) — caminho R2-first
- Antigo → Novo (apenas BD): somente leitura → backup → restaure
.sql.gzviapsql. - Conecte o R2 antes do DNS: buckets, token (Admin RW → depois Objeto RW), domínio personalizado, CORS.
env+hooks: flags de checksum +s3:upload_assets; reconstrua.- Troca de DNS para o novo host.
- Migre uploads para o R2.
- Corrija os remanescentes (reassamento/remapeamento direcionado) → rápida reexecução da migração.
- Sidekiq termina reassamentos em segundo plano (ou
posts:rebake_uncooked_posts). - Backups para o R2 verificados.
- Endurecimento de permissões e rotação de segredos.
- Limpeza de uploads locais após um período de resfriamento.
Apêndice A — “verificar antes de enviar” diário (pseudo-cron)
LATEST=$(ls -1t /var/discourse/shared/standalone/backups/default/*.sql.gz | head -n1)
# 1) integridade gzip
gzip -t "$LATEST" || exit 1
# 2) contagens de linhas em BD temporário
cd /var/discourse && ./launcher enter app <<'EOS'
sudo -E -u postgres psql -tc "DROP DATABASE IF EXISTS verifydb;"
sudo -E -u postgres createdb verifydb
zcat /shared/backups/default/$(basename '"$LATEST"') | sudo -E -u postgres psql verifydb
sudo -E -u postgres psql -d verifydb -c "select count(*) as topics from topics where deleted_at is null;"
sudo -E -u postgres psql -d verifydb -c "select count(*) as posts from posts where post_type=1 and deleted_at is null;"
sudo -E -u postgres dropdb verifydb
exit
EOS
# 3) só então envie para o R2
aws s3 cp "$LATEST" s3://discourse-backups/
Apêndice B — Proxy frontal mínimo (opcional)
Uma pequena VM de proxy reverso na frente pode terminar TLS e encaminhar para a origem via HTTPS. Substitua IPs pelos seus.
Upstream: /etc/nginx/conf.d/upstream.conf
upstream origin_forum {
server <ORIGIN_IP>:443;
keepalive 64;
}
Site: /etc/nginx/sites-available/forum.conf
server {
listen 80;
listen [::]:80;
server_name forum.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name forum.example.com;
ssl_certificate /etc/letsencrypt/live/forum.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/forum.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_timeout 1d;
client_max_body_size 100m;
add_header Strict-Transport-Security "max-age=31536000" always;
location / {
proxy_pass https://origin_forum;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host forum.example.com;
proxy_ssl_server_name on;
proxy_ssl_name forum.example.com;
# verificação opcional:
# proxy_ssl_verify on;
# proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
proxy_read_timeout 360s;
proxy_send_timeout 360s;
proxy_connect_timeout 60s;
add_header X-Relay relay-min always;
}
location /message-bus/ {
proxy_pass https://origin_forum;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host forum.example.com;
proxy_ssl_server_name on;
proxy_ssl_name forum.example.com;
proxy_buffering off;
proxy_read_timeout 3600s;
}
}
Ative e recarregue:
ln -sf /etc/nginx/sites-available/forum.conf /etc/nginx/sites-enabled/forum.conf
rm -f /etc/nginx/sites-enabled/default
nginx -t && systemctl reload nginx
Verificação rápida:
curl -I https://forum.example.com # espera HTTP/2 200/302 e cabeçalho X-Relay