Schritt-für-Schritt-Discourse-Migration mit R2 und Cloudflare-Integration

Dies ist eine Post-Mortem-Analyse/Runbook einer echten Migration. Ich lasse die üblichen Discourse-Vorbereitungen aus (die offiziellen Dokumentationen decken diese ab). Ich konzentriere mich auf die genauen Switches, Cloudflare-R2-Fallen, die wichtigen Rails/Rake-Einzeiler, was schiefgelaufen ist und wie man den gleichen Schritt beim nächsten Mal mit geringem Risiko durchführt.


Zielzustand

  • Discourse läuft auf dem neuen Host (Docker, einzelner app-Container).
  • Uploads und Frontend-Assets liegen auf Cloudflare R2:
    • Bucket discourse-uploads (öffentlich)
    • Bucket discourse-backups (privat)
  • R2-Custom-Domain: https://files.example.com (erstellt in R2 → Custom domains, nicht als manuelles Cross-Account-CNAME).

0) DB-Backups, die wirklich funktionieren (nächtlich und beim Umstieg)

Nächtliche Backups dienen der Katastrophenwiederherstellung. Ein Backup kurz vor dem Umstieg dient dem Migration-Cutover. Behalten Sie beides bei.

0.1 Richtlinie

  • Nächtlich: Nur DB-Backup (.sql.gz, keine Uploads) → lokal verifizierenauf R2 hochladen. Behalten Sie ≥7 Kopien bei (oder nutzen Sie R2-Lifecycle).
  • Cutover: Kurz vor dem DNS-Wechsel ein weiteres Nur-DB-Backup erstellen und dieses auf den neuen Host wiederherstellen, um die Content-Lücke zu minimieren.

0.2 Ein Nur-DB-Backup erstellen und verifizieren

Im Container:

# Optional, aber empfehlenswert: Schreibzugriffe während des Snapshots reduzieren
discourse enable_readonly

# Ein Nur-DB-Backup über die Admin-Oberfläche auslösen ("mit Uploads" deaktivieren)
# oder per CLI:
discourse backup

# Das Artefakt verifizieren
ls -lh /var/discourse/shared/standalone/backups/default/
zcat -t /var/discourse/shared/standalone/backups/default/<DB_ONLY>.sql.gz

Tiefe Verifizierung (am besten): Wiederherstellung auf eine temporäre DB und Zählen der Zeilen:

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

Wenn der Gzip-Test oder die temporäre Wiederherstellung fehlschlägt, laden Sie diese Datei nicht auf R2 hoch – beheben Sie den Fehler und erstellen Sie ein neues Backup.

0.3 Erst nach erfolgreicher Prüfung auf R2 hochladen

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

0.4 Warum die Größen abweichen (1–4 GB ist normal)

Sowohl das nächtliche Admin-Backup als auch manuelle pg_dump-Befehle erzeugen Nur-DB .sql.gz-Dateien. Größenunterschiede ergeben sich meist aus den enthaltenen Tabellen und der Komprimierung, nicht aus „fehlenden Beiträgen". Wenn Sie sehen möchten, was enthalten ist:

# Welche Tabellen enthalten Daten im Dump?
zcat <DB_ONLY>.sql.gz | grep -E '^COPY public\.' | awk '{print $2}' | sort -u | head

# Schnelle Zeilenzahl-Näherung für wichtige Tabellen
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

Wenn diese Zahlen den Erwartungen entsprechen, enthält das Backup alle Beiträge/Themen, unabhängig von der Dateigröße.


1) Alter Host: Vorbereiten und Kopieren des (verifizierten) Nur-DB-Backups

Wartung ankündigen → Lesezugriff aktivieren:

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

Das verifizierte .sql.gz auf den neuen Host kopieren:

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/

Wenn Sie eine nahezu null Content-Lücke wünschen, wiederholen Sie diesen Schritt kurz vor dem DNS-Cutover.


2) Bootstrap des neuen Hosts

Docker + discourse_docker installieren:

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

Erstellen Sie containers/app.yml mit Produktionswerten. Halten Sie SSL-Vorlagen auskommentiert, bis der DNS auf diesen zeigt. Minimales env-Set:

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"

  # R2-Checksummen-Einstellungen (Konflikte verhindern)
  AWS_REQUEST_CHECKSUM_CALCULATION: "WHEN_REQUIRED"
  AWS_RESPONSE_CHECKSUM_VALIDATION: "WHEN_REQUIRED"

  # SMTP / Let's Encrypt E-Mail
  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

Assets während des Neubaus auf R2 veröffentlichen:

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

Container hochfahren (vorläufig nur HTTP):

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

3) Wiederherstellen des Nur-DB-Dumps (.sql.gz via psql)

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

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

# Sicherstellen einer sauberen DB
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;"

# Dump importieren
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

Wenn Sie noch lokale Uploads vor R2 mitführen, können Sie diese einmalig als Sicherheitsnetz per rsync kopieren; wir migrieren sie im nächsten Schritt auf R2.


4) R2-Einstellungen, die wichtig waren

Buckets & Token: Erstellen Sie discourse-uploads (öffentlich) und discourse-backups (privat). Bootstrappen Sie mit einem Account-API-Token, der auf diese beiden Buckets mit Admin Read & Write beschränkt ist (damit PutBucketCors funktioniert), und rotieren Sie nach Erfolg auf Object Read & Write.

Custom Domain: Fügen Sie files.example.com in R2 → Custom domains hinzu, und zwar unter dem selben Cloudflare-Konto wie Ihre DNS-Zone (vermeidet 1014 Cross-Account-CNAME-Fehler).

CORS auf discourse-uploads:

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

Neubau durchführen, damit CSS/JS/Fonts auf R2 veröffentlicht werden:

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

5) Einmalige Migration historischer Uploads auf 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

Wenn Sie „X Beiträge nicht neu zugeordnet…" erhalten, siehe §7.2 für gezielte Fixes.


6) Produktionsdomain umstellen

In app.yml setzen:

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

DNS: forum.example.com auf die neue Front (oder Origin)-IP zeigen lassen, SSL-Vorlagen aktivieren, dann:

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

Gesundheitscheck:

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

Eine HTTP/2 403 für anonyme Benutzer bedeutet meist login_required – kein Ausfall.


7) Dinge, die tatsächlich schiefgelaufen sind (und Fixes)

7.1 R2-Checksummen-Konflikt

Aws::S3::Errors::InvalidRequest: You can only specify one non-default checksum at a time.

Fix (dauerhaft beibehalten):

AWS_REQUEST_CHECKSUM_CALCULATION: "WHEN_REQUIRED"
AWS_RESPONSE_CHECKSUM_VALIDATION: "WHEN_REQUIRED"

7.2 „X Beiträge sind nicht auf neue S3-Upload-URL remappt"

Grund: Einige cooked-HTML-Elemente zeigen immer noch auf /uploads/<db>/original/....

Gezieltes Rebacken:

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

Oder ein statisches Präfix remappen und betroffene Beiträge neu backen:

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

Die Migration erneut ausführen, um Sauberkeit zu bestätigen:

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 Tasks „fehlen"

Immer mit Bundler + Env ausführen:

sudo -E -u discourse RAILS_ENV=production bundle exec rake -T s3
sudo -E -u discourse RAILS_ENV=production bundle exec rake -T uploads

Effektive S3-Einstellungen ausgeben:

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

Verwenden Sie ein Admin RW-Token für den Bootstrap (Bucket-Level-CORS-Operationen), und rotieren Sie danach auf Object RW.


8) Verifizierung

Im Container

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

# Verbleibende cooked-Referenzen auf lokale Uploads (sollten gegen 0 gehen)
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'

Browser

  • Der Network-Tab zeigt Assets von files.example.com.
  • Alte Themen zeigen Bilder unter https://files.example.com/original/....

Backups

  • Admin → Backups → eines erstellen; bestätigen Sie, dass ein neues Objekt in discourse-backups auf R2 erscheint.

9) Bereinigung

Wenn cooked-Referenzen im Wesentlichen 0 sind:

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

# nach einigen stabilen Tagen
rm -rf /var/discourse/shared/standalone/uploads.bak

Geheimnisse rotieren (R2-Token → Object RW; SMTP-App-Passwort, falls es jemals in Logs landete).


10) Beim nächsten Mal (Playbook) — R2-first-Pfad

  1. Alt → Neu (Nur DB): Lesezugriff → Backup → Wiederherstellung .sql.gz via psql.
  2. R2 vor DNS verdrahten: Buckets, Token (Admin RW → später Object RW), Custom Domain, CORS.
  3. env + hooks: Checksummen-Flags + s3:upload_assets; Neubau.
  4. DNS-Cutover auf den neuen Host.
  5. Uploads auf R2 migrieren.
  6. Nachzügler beheben (gezieltes Rebacken/Remappen) → schnelles erneutes Ausführen der Migration.
  7. Sidekiq beendet Hintergrund-Rebakes (oder posts:rebake_uncooked_posts).
  8. Backups auf R2 verifiziert.
  9. Berechtigungen härten und Geheimnisse rotieren.
  10. Lokale Uploads nach einer Auszeit bereinigen.

Anhang A — „Vor dem Hochladen verifizieren

6 „Gefällt mir“