Migração do Discourse passo a passo com integração R2 e Cloudflare

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)
  • 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 localmenteenviar 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-backups no 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

  1. Antigo → Novo (apenas BD): somente leitura → backup → restaure .sql.gz via psql.
  2. Conecte o R2 antes do DNS: buckets, token (Admin RW → depois Objeto RW), domínio personalizado, CORS.
  3. env + hooks: flags de checksum + s3:upload_assets; reconstrua.
  4. Troca de DNS para o novo host.
  5. Migre uploads para o R2.
  6. Corrija os remanescentes (reassamento/remapeamento direcionado) → rápida reexecução da migração.
  7. Sidekiq termina reassamentos em segundo plano (ou posts:rebake_uncooked_posts).
  8. Backups para o R2 verificados.
  9. Endurecimento de permissões e rotação de segredos.
  10. 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
6 curtidas