Questo è un post-mortem/runbook di una migrazione reale. Ometto le preparazioni comuni di Discourse (i documenti ufficiali le coprono). Mi concentro sugli switch esatti, sulle insidie di Cloudflare R2, sui comandi one-liner di rails/rake che contavano, su cosa è andato storto e su come rendere lo stesso passaggio a basso rischio la prossima volta.
Stato finale obiettivo
- Discourse gira sul nuovo host (Docker, singolo container
app). - Upload e asset front-end risiedono su Cloudflare R2:
- Bucket
discourse-uploads(pubblico) - Bucket
discourse-backups(privato)
- Bucket
- Dominio personalizzato R2:
https://files.example.com(creato in R2 → Custom domains, non tramite un CNAME manuale cross-account).
0) Backup DB che funzionano davvero (notturni e al momento del cutover)
I backup notturni servono per il disaster recovery. Un backup dell’ultimo minuto serve per il cutover della migrazione. Mantienili entrambi.
0.1 Politica
- Notturno: backup solo DB (
.sql.gz, senza upload) → verifica locale → carica su R2. Mantieni ≥7 copie (o usa il ciclo di vita di R2). - Cutover: subito prima dello switch DNS, crea un altro backup solo DB e ripristinalo sul nuovo host per minimizzare il divario di contenuti.
0.2 Crea un backup solo DB e verifica
All’interno del container:
# Opzionale ma utile: ridurre le scritture durante lo snapshot
discourse enable_readonly
# Attiva un backup solo DB dall'interfaccia di amministrazione (deseleziona "with uploads")
# o da CLI:
discourse backup
# Verifica l'artefatto
ls -lh /var/discourse/shared/standalone/backups/default/
zcat -t /var/discourse/shared/standalone/backups/default/<DB_ONLY>.sql.gz
Verifica approfondita (migliore): ripristina su un DB temporaneo e conta le righe:
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 il test gzip o il ripristino temporaneo falliscono, non caricare quel file su R2: correggi e rifai il backup.
0.3 Carica su R2 solo dopo il superamento dei test
aws s3 cp /var/discourse/shared/standalone/backups/default/<DB_ONLY>.sql.gz \
s3://discourse-backups/
0.4 Perché le dimensioni differiscono (1–4 GB è normale)
Sia il backup notturno di Admin che il pg_dump manuale producono file .sql.gz solo DB. Le differenze di dimensione sono solitamente dovute alle tabelle incluse e alla compressione, non a “post mancanti”. Se vuoi vedere cosa c’è dentro:
# Quali tabelle hanno dati nel dump?
zcat <DB_ONLY>.sql.gz | grep -E '^COPY public\.' | awk '{print $2}' | sort -u | head
# Approssimazione rapida del conteggio delle righe per le tabelle chiave
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 quei conteggi corrispondono alle aspettative, il backup contiene tutti i post/argomenti indipendentemente dalla dimensione del file.
1) Host vecchio: prepara e copia il backup (verificato) solo DB
Annuncia la manutenzione → attiva sola lettura:
cd /var/discourse && ./launcher enter app
discourse enable_readonly
exit
Copia il .sql.gz verificato sul nuovo 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 desideri un divario di contenuti quasi nullo, ripeti questo passaggio subito prima del cutover DNS.
2) Bootstrap del nuovo host
Installa 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
Crea containers/app.yml con valori di produzione. Tieni i template SSL commentati finché il DNS non punta qui. Set minimo di 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"
# Knob di checksum R2 (prevenire conflitti)
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
Pubblica gli asset su R2 durante la ricostruzione:
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
Avvia il container (per ora solo HTTP):
cd /var/discourse && ./launcher rebuild app
3) Ripristina il dump solo DB (.sql.gz via psql)
cd /var/discourse && ./launcher enter app
sv stop unicorn || true; sv stop sidekiq || true
# assicurati di un DB pulito
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;"
# importa il 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 stai ancora trasportando upload locali pre-R2, puoi sincronizzarli una volta come rete di sicurezza; li migreremo su R2 dopo.
4) Knob R2 che contavano
Bucket e token: crea discourse-uploads (pubblico) e discourse-backups (privato). Inizializza con un Account API Token con ambito limitato a quei due bucket con Admin Read & Write (così PutBucketCors funziona), poi ruota a Object Read & Write dopo il successo.
Dominio personalizzato: aggiungi files.example.com in R2 → Custom domains sotto lo stesso account Cloudflare della tua zona DNS (evita errori CNAME cross-account 1014).
CORS su discourse-uploads:
[
{
"AllowedOrigins": ["https://forum.example.com","https://files.example.com"],
"AllowedMethods": ["GET","HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["*"],
"MaxAgeSeconds": 86400
}
]
Ricostruisci in modo che CSS/JS/font vengano pubblicati su R2:
cd /var/discourse && ./launcher rebuild app
5) Migrazione una tantum degli upload storici su 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 ricevi “X posts not remapped…”, vedi §7.2 per correzioni mirate.
6) Switch del dominio di produzione
Imposta in app.yml:
DISCOURSE_HOSTNAME: forum.example.com
LETSENCRYPT_ACCOUNT_EMAIL: you@example.com
DNS: punta forum.example.com al nuovo front (o origin) IP, abilita i template SSL, poi:
cd /var/discourse && ./launcher rebuild app
Sanity check:
curl -I https://forum.example.com
./launcher logs app | tail -n 200
Vedere HTTP/2 403 per gli anonimi di solito significa login_required — non un’interruzione.
7) Cose che si sono rotte davvero (e relative correzioni)
7.1 Conflitto di checksum R2
Aws::S3::Errors::InvalidRequest: You can only specify one non-default checksum at a time.
Correzione (mantieni permanentemente):
AWS_REQUEST_CHECKSUM_CALCULATION: "WHEN_REQUIRED"
AWS_RESPONSE_CHECKSUM_VALIDATION: "WHEN_REQUIRED"
7.2 “X posts are not remapped to new S3 upload URL”
Motivo: alcuni HTML cooked puntano ancora a /uploads/<db>/original/....
Rebake mirato:
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 rimappa un prefisso statico poi rebaka i post toccati:
sudo -E -u discourse RAILS_ENV=production bundle exec \
rake "posts:remap[/uploads/default/original,https://files.example.com/original]"
Esegui di nuovo la migrazione per confermare la pulizia:
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 Task “mancanti”
Esegui sempre 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
Stampa le impostazioni S3 effettive:
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
Usa un token Admin RW per il bootstrap (operazioni CORS a livello di bucket), poi ruota a Object RW.
8) Verifica
All’interno del container
# URL ora usano la CDN
sudo -E -u discourse RAILS_ENV=production bundle exec rails r \
'puts Upload.where("url LIKE ?", "%files.example.com%").limit(5).pluck(:url)'
# Riferimenti cooked rimanenti agli upload locali (dovrebbero tendere 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'
Browser
- La scheda Rete mostra asset da
files.example.com. - Gli argomenti vecchi mostrano immagini sotto
https://files.example.com/original/....
Backup
- Admin → Backups → crea uno; conferma che un nuovo oggetto appaia in
discourse-backupssu R2.
9) Pulizia
Quando i riferimenti cooked sono essenzialmente 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
# dopo alcuni giorni stabili
rm -rf /var/discourse/shared/standalone/uploads.bak
Ruota i segreti (token R2 → Object RW; password app SMTP se ha mai toccato i log).
10) La prossima volta (playbook) — percorso R2-first
- Vecchio → Nuovo (solo DB): sola lettura → backup → ripristino
.sql.gzviapsql. - Collega R2 prima del DNS: bucket, token (Admin RW → poi Object RW), dominio personalizzato, CORS.
env+hooks: flag di checksum +s3:upload_assets; ricostruisci.- Cutover DNS sul nuovo host.
- Migra gli upload su R2.
- Correggi i ritardatari (rebake/remap mirati) → riesegui rapidamente la migrazione.
- Sidekiq termina i rebake in background (o
posts:rebake_uncooked_posts). - Backup su R2 verificati.
- Irrigidimento permessi e rotazione segreti.
- Pulisci gli upload locali dopo un periodo di raffreddamento.
Appendice A — “verifica prima del caricamento” notturno (pseudo-cron)
LATEST=$(ls -1t /var/discourse/shared/standalone/backups/default/*.sql.gz | head -n1)
# 1) integrità gzip
gzip -t "$LATEST" || exit 1
# 2) conteggi righe DB temporaneo
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 allora carica su R2
aws s3 cp "$LATEST" s3://discourse-backups/
Appendice B — Proxy front-end minimale (opzionale)
Una piccola VM proxy inverso davanti può terminare TLS e inoltrare all’origin via HTTPS. Sostituisci gli IP con i tuoi.
Upstream: /etc/nginx/conf.d/upstream.conf
upstream origin_forum {
server <ORIGIN_IP>:443;
keepalive 64;
}
Sito: /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 opzionale:
# 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;
}
}
Abilita e ricarica:
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
Controllo rapido:
curl -I https://forum.example.com # ci si aspetta HTTP/2 200/302 e header X-Relay