Migración paso a paso de Discourse con integración de R2 y Cloudflare

Este es un post-mortem/runbook de una migración real. Omito la preparación común de Discourse (la documentación oficial la cubre). Me centro en los cambios exactos, los problemas específicos de Cloudflare R2, las líneas de comando de rails/rake que importaron, lo que falló y cómo repetir el mismo movimiento con bajo riesgo la próxima vez.


Estado final objetivo

  • Discourse se ejecuta en el nuevo host (Docker, contenedor único app).
  • Las cargas y los activos del front-end residen en Cloudflare R2:
    • Bucket discourse-uploads (público)
    • Bucket discourse-backups (privado)
  • Dominio personalizado de R2: https://files.example.com (creado en R2 → Dominios personalizados, no un CNAME manual entre cuentas).

0) Copias de seguridad de la BD que realmente funcionan (diarias y durante el corte)

Las copias de seguridad diarias son para recuperación ante desastres. Una copia de seguridad de último minuto es para el corte de la migración. Mantén ambas.

0.1 Política

  • Diaria: Copia de seguridad solo de BD (.sql.gz, sin cargas) → verificar localmentesubir a R2. Mantén ≥7 copias (o usa el ciclo de vida de R2).
  • Corte: justo antes del cambio de DNS, realiza otra copia de seguridad solo de BD y restaura esa en el nuevo host para minimizar la brecha de contenido.

0.2 Realizar una copia de seguridad solo de BD y verificarla

Dentro del contenedor:

# Opcional pero recomendado: reducir escrituras durante la instantánea
discourse enable_readonly

# Iniciar una copia de seguridad solo de BD desde la interfaz de Admin (desmarcar "con cargas")
# o por CLI:
discourse backup

# Verificar el artefacto
ls -lh /var/discourse/shared/standalone/backups/default/
zcat -t /var/discourse/shared/standalone/backups/default/<DB_ONLY>.sql.gz

Verificación profunda (mejor): restaurar en una BD temporal y contar filas:

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

Si la prueba de gzip o la restauración temporal falla, no subas ese archivo a R2; corrige y vuelve a hacer la copia de seguridad.

0.3 Subir a R2 solo después de que pase la verificación

aws s3 cp /var/discourse/shared/standalone/backups/default/<DB_ONLY>.sql.gz \
  s3://discourse-backups/

0.4 Por qué difieren los tamaños (1–4 GB es normal)

Tanto la copia de seguridad nocturna de Admin como pg_dump manual producen archivos .sql.gz solo de BD. Las diferencias de tamaño suelen deberse a las tablas incluidas y la compresión, no a “publicaciones faltantes”. Si quieres ver qué hay dentro:

# ¿Qué tablas tienen datos en el volcado?
zcat <DB_ONLY>.sql.gz | grep -E '^COPY public\.' | awk '{print $2}' | sort -u | head

# Aproximación rápida de conteo de líneas para tablas clave
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

Si esos conteos coinciden con lo esperado, la copia de seguridad contiene todas las publicaciones/temas independientemente del tamaño del archivo.


1) Host antiguo: preparar y copiar la copia de seguridad (verificada) solo de BD

Anunciar mantenimiento → habilitar solo lectura:

cd /var/discourse && ./launcher enter app
discourse enable_readonly
exit

Copiar el .sql.gz verificado al nuevo 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/

Si deseas una brecha de contenido casi nula, repite este paso justo antes del corte de DNS.


2) Inicio del nuevo host

Instalar 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

Crear containers/app.yml con valores de producción. Mantén las plantillas SSL comentadas hasta que DNS apunte aquí. 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"

  # Parámetros de checksum de R2 (evitar conflictos)
  AWS_REQUEST_CHECKSUM_CALCULATION: "WHEN_REQUIRED"
  AWS_RESPONSE_CHECKSUM_VALIDATION: "WHEN_REQUIRED"

  # SMTP / Correo de 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

Publicar activos en R2 durante la reconstrucción:

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

Levantar el contenedor (solo HTTP por ahora):

cd /var/discourse && ./launcher rebuild app

3) Restaurar el volcado solo de BD (.sql.gz vía psql)

cd /var/discourse && ./launcher enter app

sv stop unicorn || true; sv stop sidekiq || true

# asegurar una BD limpia
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;"

# importar el volcado
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

Si aún estás transportando cargas locales previas a R2, puedes sincronizarlas una vez como red de seguridad; las migraremos a R2 a continuación.


4) Parámetros de R2 que importaron

Buckets y token: crear discourse-uploads (público) y discourse-backups (privado). Inicializar con un Token de API de Cuenta con ámbito en esos dos buckets con Lectura y Escritura de Administrador (para que funcione PutBucketCors), luego rotar a Lectura y Escritura de Objetos después del éxito.

Dominio personalizado: agregar files.example.com en R2 → Dominios personalizados bajo la misma cuenta de Cloudflare que tu zona DNS (evita errores 1014 de CNAME entre cuentas).

CORS en discourse-uploads:

[
  {
    "AllowedOrigins": ["https://forum.example.com","https://files.example.com"],
    "AllowedMethods": ["GET","HEAD"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["*"],
    "MaxAgeSeconds": 86400
  }
]

Reconstruir para que CSS/JS/fuentes se publiquen en R2:

cd /var/discourse && ./launcher rebuild app

5) Migración única de cargas históricas a 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

Si obtienes “X publicaciones no remapeadas…”, consulta §7.2 para correcciones específicas.


6) Cambiar dominio de producción

Establecer en app.yml:

DISCOURSE_HOSTNAME: forum.example.com
LETSENCRYPT_ACCOUNT_EMAIL: you@example.com

DNS: apuntar forum.example.com a la nueva interfaz (o origen), habilitar plantillas SSL, luego:

cd /var/discourse && ./launcher rebuild app

Sanidad:

curl -I https://forum.example.com
./launcher logs app | tail -n 200

Ver HTTP/2 403 para anónimos usualmente significa login_required — no es una interrupción.


7) Cosas que realmente fallaron (y soluciones)

7.1 Conflicto de checksum de R2

Aws::S3::Errors::InvalidRequest: Solo puedes especificar un checksum no predeterminado a la vez.

Solución (mantener permanentemente):

AWS_REQUEST_CHECKSUM_CALCULATION: "WHEN_REQUIRED"
AWS_RESPONSE_CHECKSUM_VALIDATION: "WHEN_REQUIRED"

7.2 “X publicaciones no se remapearon a la nueva URL de carga de S3”

Razón: algunos HTML cooked aún apuntan a /uploads/<db>/original/....

Rehorneo dirigido:

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}"
'

O remapear un prefijo estático y luego rehornear las publicaciones afectadas:

sudo -E -u discourse RAILS_ENV=production bundle exec \
rake "posts:remap[/uploads/default/original,https://files.example.com/original]"

Volver a ejecutar la migración para confirmar que está limpio:

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 Tareas “faltantes”

Siempre ejecutar con 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

Imprimir configuración efectiva de 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

Usar un token Admin RW para la inicialización (operaciones de CORS a nivel de bucket), luego rotar a Object RW.


8) Verificación

Dentro del contenedor

# URLs usando ahora el CDN
sudo -E -u discourse RAILS_ENV=production bundle exec rails r \
'puts Upload.where("url LIKE ?", "%files.example.com%").limit(5).pluck(:url)'

# Referencias restantes en cooked a cargas locales (debería 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

  • La pestaña Red muestra activos desde files.example.com.
  • Temas antiguos muestran imágenes bajo https://files.example.com/original/....

Copias de seguridad

  • Admin → Backups → crear una; confirmar que aparece un nuevo objeto en discourse-backups en R2.

9) Limpieza

Cuando las referencias en cooked sean esencialmente 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

# después de unos días estables
rm -rf /var/discourse/shared/standalone/uploads.bak

Rotar secretos (token R2 → Object RW; contraseña de aplicación SMTP si alguna vez llegó a los registros).


10) Próxima vez (manual) — Ruta R2-first

  1. Antiguo → Nuevo (solo BD): solo lectura → copia de seguridad → restaurar .sql.gz vía psql.
  2. Conectar R2 antes de DNS: buckets, token (Admin RW → luego Object RW), dominio personalizado, CORS.
  3. env + hooks: banderas de checksum + s3:upload_assets; reconstruir.
  4. Corte de DNS al nuevo host.
  5. Migrar cargas a R2.
  6. Corregir rezagados (rehorneo/remapeo dirigido) → rápida reejecución de la migración.
  7. Sidekiq termina rehorneos en segundo plano (o posts:rebake_uncooked_posts).
  8. Copias de seguridad a R2 verificadas.
  9. Endurecimiento de permisos y rotación de secretos.
  10. Limpiar cargas locales después de un periodo de enfriamiento.

Apéndice A — “verificar antes de subir” nocturno (pseudo-cron)

LATEST=$(ls -1t /var/discourse/shared/standalone/backups/default/*.sql.gz | head -n1)

# 1) integridad gzip
gzip -t "$LATEST" || exit 1

# 2) conteos de filas en BD temporal
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) solo entonces subir a R2
aws s3 cp "$LATEST" s3://discourse-backups/

Apéndice B — Proxy frontal mínimo (opcional)

Una pequeña VM de proxy inverso al frente puede terminar TLS y reenviar al origen sobre HTTPS. Reemplaza las IPs con las tuyas.

Upstream: /etc/nginx/conf.d/upstream.conf

upstream origin_forum {
    server <ORIGIN_IP>:443;
    keepalive 64;
}

Sitio: /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;
        # verificación 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;
    }
}

Habilitar y recargar:

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

Verificación rápida:

curl -I https://forum.example.com   # esperar HTTP/2 200/302 y cabecera X-Relay
6 Me gusta