Voici un post-mortem / runbook d’une migration réelle. Je passe les préparatifs Discourse courants (la documentation officielle les couvre). Je me concentre sur les basculements exacts, les pièges de Cloudflare R2, les commandes rails/rake en une ligne qui comptaient, ce qui a échoué, et comment reproduire ce déplacement à faible risque la prochaine fois.
État final cible
- Discourse s’exécute sur le nouvel hôte (Docker, conteneur
appunique). - Les uploads et les assets front-end résident sur Cloudflare R2 :
- Bucket
discourse-uploads(public) - Bucket
discourse-backups(privé)
- Bucket
- Domaine personnalisé R2 :
https://files.example.com(créé dans R2 → Domaines personnalisés, pas un CNAME inter-compte manuel).
0) Sauvegardes de base de données qui fonctionnent vraiment (quotidiennes et lors du basculement)
Les sauvegardes quotidiennes servent à la reprise après sinistre. Une dernière sauvegarde immédiate sert au basculement de migration. Conservez les deux.
0.1 Politique
- Quotidienne : sauvegarde DB-only (
.sql.gz, sans uploads) → vérification locale → envoi vers R2. Conservez ≥7 copies (ou utilisez le cycle de vie R2). - Basculement : juste avant le basculement DNS, effectuez une autre sauvegarde DB-only et restaurez-la sur le nouvel hôte pour minimiser l’écart de contenu.
0.2 Créer une sauvegarde DB-only et la vérifier
Dans le conteneur :
# Optionnel mais recommandé : réduire les écritures pendant la prise d'instantané
discourse enable_readonly
# Déclencher une sauvegarde DB-only depuis l'interface d'administration (décocher "avec uploads")
# ou en CLI :
discourse backup
# Vérifier l'artefact
ls -lh /var/discourse/shared/standalone/backups/default/
zcat -t /var/discourse/shared/standalone/backups/default/<DB_ONLY>.sql.gz
Vérification approfondie (meilleure) : restaurer dans une base de données temporaire et compter les lignes :
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 le test gzip ou la restauration temporaire échoue, ne téléversez pas ce fichier sur R2 — corrigez et refaites la sauvegarde.
0.3 Envoyer vers R2 uniquement après réussite
aws s3 cp /var/discourse/shared/standalone/backups/default/<DB_ONLY>.sql.gz \
s3://discourse-backups/
0.4 Pourquoi les tailles diffèrent (1–4 Go est normal)
Les sauvegardes quotidiennes Admin et le pg_dump manuel produisent tous deux des fichiers DB-only .sql.gz. Les différences de taille proviennent généralement des tables incluses et de la compression, et non de “posts manquants”. Si vous voulez voir ce qu’il y a dedans :
# Quelles tables contiennent des données dans le dump ?
zcat <DB_ONLY>.sql.gz | grep -E '^COPY public\.' | awk '{print $2}' | sort -u | head
# Approximation rapide du nombre de lignes pour les tables clés
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 ces comptes correspondent aux attentes, la sauvegarde contient tous les posts/sujets quelle que soit la taille du fichier.
1) Ancien hôte : préparer et copier la sauvegarde (vérifiée) DB-only
Annoncez la maintenance → activez le mode lecture seule :
cd /var/discourse && ./launcher enter app
discourse enable_readonly
exit
Copiez le .sql.gz vérifié vers le nouvel hôte :
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 vous voulez un écart de contenu presque nul, répétez cette étape juste avant le basculement DNS.
2) Amorçage du nouvel hôte
Installer 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
Créez containers/app.yml avec des valeurs de production. Gardez les modèles SSL commentés jusqu’à ce que le DNS pointe ici. Jeu minimum 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"
# Réglages de checksum R2 (éviter les conflits)
AWS_REQUEST_CHECKSUM_CALCULATION: "WHEN_REQUIRED"
AWS_RESPONSE_CHECKSUM_VALIDATION: "WHEN_REQUIRED"
# SMTP / Email 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
Publier les assets sur R2 pendant la reconstruction :
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
Démarrer le conteneur (HTTP uniquement pour l’instant) :
cd /var/discourse && ./launcher rebuild app
3) Restaurer le dump DB-only (.sql.gz via psql)
cd /var/discourse && ./launcher enter app
sv stop unicorn || true; sv stop sidekiq || true
# s'assurer d'une base de données propre
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;"
# importer le 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
Si vous conservez encore des uploads locaux avant R2, vous pouvez les rsync une fois comme filet de sécurité ; nous les migrerons vers R2 ensuite.
4) Réglages R2 qui comptaient
Buckets & token : créer discourse-uploads (public) et discourse-backups (privé). Amorcer avec un Token API de compte étendu à ces deux buckets avec Lecture et Écriture Admin (pour que PutBucketCors fonctionne), puis basculer vers Lecture et Écriture Objet après réussite.
Domaine personnalisé : ajouter files.example.com dans R2 → Domaines personnalisés sous le même compte Cloudflare que votre zone DNS (évite les erreurs de CNAME inter-compte 1014).
CORS sur discourse-uploads :
[
{
"AllowedOrigins": ["https://forum.example.com","https://files.example.com"],
"AllowedMethods": ["GET","HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["*"],
"MaxAgeSeconds": 86400
}
]
Reconstruire pour que CSS/JS/polices soient publiés sur R2 :
cd /var/discourse && ./launcher rebuild app
5) Migration en une fois des uploads historiques vers 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 vous obtenez « X posts non remappés… », consultez §7.2 pour des correctifs ciblés.
6) Basculement du domaine de production
Définir dans app.yml :
DISCOURSE_HOSTNAME: forum.example.com
LETSENCRYPT_ACCOUNT_EMAIL: you@example.com
DNS : pointer forum.example.com vers la nouvelle façade (ou l’origine), activer les modèles SSL, puis :
cd /var/discourse && ./launcher rebuild app
Vérification de bon sens :
curl -I https://forum.example.com
./launcher logs app | tail -n 200
Voir HTTP/2 403 pour les anonymes signifie généralement login_required — pas une panne.
7) Choses qui ont vraiment planté (et correctifs)
7.1 Conflit de checksum R2
Aws::S3::Errors::InvalidRequest: You can only specify one non-default checksum at a time.
Correctif (garder en permanence) :
AWS_REQUEST_CHECKSUM_CALCULATION: "WHEN_REQUIRED"
AWS_RESPONSE_CHECKSUM_VALIDATION: "WHEN_REQUIRED"
7.2 « X posts ne sont pas remappés vers la nouvelle URL d’upload S3 »
Raison : certains HTML cooked pointent encore vers /uploads/<db>/original/....
Rebake ciblé :
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 remapper un préfixe statique puis rebaker les posts touchés :
sudo -E -u discourse RAILS_ENV=production bundle exec \
rake "posts:remap[/uploads/default/original,https://files.example.com/original]"
Relancer la migration pour confirmer la propreté :
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 Tâches « manquantes »
Toujours exécuter avec 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
Imprimer les paramètres S3 effectifs :
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
Utiliser un token Admin RW pour l’amorçage (opérations CORS au niveau du bucket), puis basculer vers Objet RW.
8) Vérification
Dans le conteneur
# URL utilisant désormais le CDN
sudo -E -u discourse RAILS_ENV=production bundle exec rails r \
'puts Upload.where("url LIKE ?", "%files.example.com%").limit(5).pluck(:url)'
# Références cooked restantes vers les uploads locaux (devrait tendre vers 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'
Navigateur
- L’onglet Réseau montre les assets provenant de
files.example.com. - Les anciens sujets affichent les images sous
https://files.example.com/original/....
Sauvegardes
- Admin → Sauvegardes → en créer une ; confirmer qu’un nouvel objet apparaît dans
discourse-backupssur R2.
9) Nettoyage
Lorsque les références cooked sont essentiellement à 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
# après quelques jours stables
rm -rf /var/discourse/shared/standalone/uploads.bak
Faire tourner les secrets (token R2 → Objet RW ; mot de passe d’application SMTP s’il a jamais touché les logs).
10) La prochaine fois (playbook) — chemin R2-first
- Ancien → Nouveau (DB-only) : lecture seule → sauvegarde → restauration
.sql.gzviapsql. - Câbler R2 avant DNS : buckets, token (Admin RW → plus tard Objet RW), domaine personnalisé, CORS.
env+hooks: drapeaux de checksum +s3:upload_assets; reconstruire.- Basculement DNS vers le nouvel hôte.
- Migrer les uploads vers R2.
- Corriger les retardataires (rebake/remap ciblé) → réexécution rapide de la migration.
- Sidekiq termine les rebakes en arrière-plan (ou
posts:rebake_uncooked_posts). - Sauvegardes vers R2 vérifiées.
- Durcissement des permissions et rotation des secrets.
- Nettoyer les uploads locaux après une période de latence.
Annexe A — « vérifier-avant-téléversement » quotidien (pseudo-cron)
LATEST=$(ls -1t /var/discourse/shared/standalone/backups/default/*.sql.gz | head -n1)
# 1) intégrité gzip
gzip -t "$LATEST" || exit 1
# 2) comptes de lignes de base de données temporaire
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) seulement alors envoyer vers R2
aws s3 cp "$LATEST" s3://discourse-backups/
Annexe B — Proxy frontal minimal (optionnel)
Une petite VM de proxy inverse à l’avant peut terminer TLS et transférer vers l’origine sur HTTPS. Remplacez les IPs par les vôtres.
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;
# vérification optionnelle :
# 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;
}
}
Activer et recharger :
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
Vérification rapide :
curl -I https://forum.example.com # attendre HTTP/2 200/302 et l'en-tête X-Relay