Sebbene sia più conveniente e sicuro distribuire Discourse seguendo la guida ufficiale di installazione, voglio approfondire il funzionamento del container e vedere come può essere distribuito su Linux senza Docker. Condivido i passaggi solo a scopo informativo. Adattali e utilizzali a tuo rischio e pericolo.
Approfondisci il funzionamento di Discourse nel container
Dai un’occhiata all’output di ./launcher start-cmd webonly:
true run --shm-size=512m --link data:data -d --restart=always -e LANG=en_US.UTF-8 -e RAILS_ENV=production … --name webonly -t -v /var/discourse/shared/webonly:/shared … local_discourse/webonly /sbin/boot
Poi osservo /sbin/boot e /etc/service/unicorn/run, ottenendo il comando principale per avviare Discourse:
LD_PRELOAD=$RUBY_ALLOCATOR HOME=/home/discourse USER=discourse exec thpoff chpst -u discourse:www-data -U discourse:www-data bundle exec config/unicorn_launcher -E production -c config/unicorn.conf.rb
preparare il sistema
Per tua informazione, utilizzo Ubuntu 24.04 e zsh.
Segui la guida di installazione ufficiale di PG per installare PostgreSQL dal repository Apt di PostgreSQL. Ho installato la versione 18, che funziona molto bene, sebbene l’installazione ufficiale al momento della scrittura utilizzi la versione 15.
Installa redis (8.2 al momento della scrittura, l’installazione ufficiale usa 7.0), nginx e crea un utente dedicato discourse:
apt install nginx libnginx-mod-http-brotli-static redis zsh zsh-autosuggestions zsh-syntax-highlighting
systemctl enable --now postgresql redis nginx
useradd -m -s /bin/zsh discourse
Installa ImageMagick 7 (io uso IMEI) e verifica la versione. La mia è:
magick --version
Version: ImageMagick 7.1.2-3 Q16-HDRI
Poi, cambia utente (su - discourse) e installa pnpm, rvm.
curl -fsSL https://get.pnpm.io/install.sh | zsh -
curl -sSL https://get.rvm.io | bash
Quindi adatta e aggiungi la seguente configurazione al tuo .zshrc
/home/discourse/.zshrc
# pnpm
export PNPM_HOME="/home/discourse/.local/share/pnpm"
case ":$PATH:" in
*":$PNPM_HOME:"*) ;;
*) export PATH="$PNPM_HOME:$PATH" ;;
esac
# fine pnpm
alias npm='pnpm'
alias npx='pnpx'
# Aggiungi RVM al PATH per lo scripting. Assicurati che questo sia l'ultimo cambiamento alla variabile PATH.
export PATH="$PATH:$HOME/.rvm/bin"
export ALLOW_EMBER_CLI_PROXY_BYPASS=1
export RAILS_ENV=production
export UNICORN_SIDEKIQ_MAX_RSS=1000
export UNICORN_WORKERS=4
export UNICORN_SIDEKIQS=1
export PUMA_SIDEKIQ_MAX_RSS=1000
export PUMA_WORKERS=4
export PUMA_SIDEKIQS=1
#export RUBY_YJIT_ENABLE=1
#export RUBY_CONFIGURE_OPTS="--enable-yjit"
export DISCOURSE_HOSTNAME=example.com
export DISCOURSE_DEVELOPER_EMAILS=discourse-admin@example.com
export DISCOURSE_MAXMIND_ACCOUNT_ID=<id>
export DISCOURSE_MAXMIND_LICENSE_KEY=<key>
export DISCOURSE_ENABLE_CORS=true
export DISCOURSE_MAX_REQS_PER_IP_MODE=none
export DISCOURSE_MAX_REQS_PER_IP_PER_MINUTE=20000
export DISCOURSE_MAX_REQS_PER_IP_PER_10_SECONDS=5000
export DISCOURSE_MAX_ASSET_REQS_PER_IP_PER_10_SECONDS=20000
export DISCOURSE_MAX_REQS_RATE_LIMIT_ON_PRIVATE=false
export DISCOURSE_MAX_USER_API_REQS_PER_MINUTE=200
export DISCOURSE_MAX_USER_API_REQS_PER_DAY=28800
export DISCOURSE_MAX_ADMIN_API_REQS_PER_MINUTE=600
export DISCOURSE_MAX_DATA_EXPLORER_API_REQ_MODE=none
export DISCOURSE_MAX_REQS_PER_IP_EXCEPTIONS="127.0.0.1 ::1"
cd /var/www/discourse
Esci e accedi di nuovo come discourse affinché le modifiche a .zshrc abbiano effetto.
Installa node e ruby:
pnpm env use --global latest # installerà node 24.9 al momento della scrittura. l'installazione ufficiale usa 22
rvm get master
rvm install 3.4 # installerà ruby 3.4.6 al momento della scrittura. l'installazione ufficiale usa 3.3
rvm use 3.4 --default
preparare il db (e ripristinare un backup)
sudo -u postgres createuser -s discourse
sudo -u postgres createdb discourse
$sudo -u postgres psql discourse
psql>
ALTER USER discourse WITH PASSWORD 'xxx';
CREATE EXTENSION hstore;CREATE EXTENSION pg_trgm;
CREATE EXTENSION plpgsql;
CREATE EXTENSION unaccent;
CREATE EXTENSION vector;
# per ripristinare il database estratto da un backup:
$ gunzip < dump.sql.gz | psql discourse
Per ripristinare un backup, devi anche copiare le cartelle public e plugins.
installare Discourse
Ho consultato discourse_docker/templates/web.template.yml at 20e33fbfd98d3b8d9c57f7a111beff8aa51a5b98 · discourse/discourse_docker · GitHub
come utente root:
cd /var/www/
git clone https://github.com/discourse/discourse
mkdir -p /var/www/discourse/public
chown -R discourse:discourse /var/www/discourse/
chown -R discourse:www-data /var/www/discourse/public
configura config/discourse.conf:
config/discourse.conf
max_data_explorer_api_req_mode = 'none'
max_user_api_reqs_per_day = '28800'
hostname = '127.0.0.1'
hostname = 'example.com'
redis_host = '127.0.0.1'
db_password = '<password>'
db_socket = ''
max_reqs_per_ip_per_10_seconds = '5000'
max_asset_reqs_per_ip_per_10_seconds = '20000'
max_reqs_rate_limit_on_private = 'false'
developer_emails = 'discourse-admin@example.com'
max_user_api_reqs_per_minute = '200'
maxmind_license_key = '<key>'
maxmind_account_id = '<id>'
max_reqs_per_ip_per_minute = '20000'
db_host = '127.0.0.1'
enable_cors = 'true'
db_port = ''
max_reqs_per_ip_mode = 'none'
max_admin_api_reqs_per_minute = '600'
smtp_user_name = '<name>'
smtp_address = 'postal.example.com'
smtp_port = '25'
smtp_password = '<password>'
smtp_domain = 'postalsend.example.com'
notification_email = 'noreply@postalsend.example.com'
Esegui l’installazione di bundle/pnpm, la migrazione del database e la precompilazione degli asset. Questo è anche il metodo per aggiornare Discourse e i plugin.
come utente discourse:
cd /var/www/discourse
git stash
git pull
git checkout tests-passed
cd plugins
for plugin in *
do
echo $plugin; cd ${plugin}; git pull; cd ..
done
cd ../
sed -i '/gem "rails_multisite"/i gem "rails"' Gemfile
bundle install --jobs $(($(nproc) - 1))
pnpm i
bundle exec rake db:migrate
bundle exec rake themes:update
bundle exec rake assets:precompile
Non voglio usare unicorn. Heroku consiglia di utilizzare il server web Puma invece di Unicorn. Ecco il mio config/puma.rb scritto dopo aver consultato config/unicorn.conf.rb:
config/puma.rb
# frozen_string_literal: true
require "fileutils"
#require 'puma/acme'
discourse_path = File.expand_path(File.expand_path(File.dirname(__FILE__)) + "/../")
enable_logstash_logger = ENV["ENABLE_LOGSTASH_LOGGER"] == "1"
puma_stderr_path = "#{discourse_path}/log/puma.stderr.log"
puma_stdout_path = "#{discourse_path}/log/puma.stdout.log"
# Carica il logger logstash se abilitato
if enable_logstash_logger
require_relative "../lib/discourse_logstash_logger"
FileUtils.touch(puma_stderr_path) if !File.exist?(puma_stderr_path)
# Nota: Potrebbe essere necessario adattare l'inizializzazione del logger per Puma
log_formatter =
proc do |severity, time, progname, msg|
event = {
"@timestamp" => Time.now.utc,
"message" => msg,
"severity" => severity,
"type" => "puma",
}
"#{event.to_json}\n"
end
else
stdout_redirect puma_stdout_path, puma_stderr_path, true
end
# Numero di workers (processi)
workers ENV.fetch("PUMA_WORKERS", 6).to_i
# Imposta la directory
directory discourse_path
# Associa all'indirizzo e alla porta specificati
bind ENV.fetch(
"PUMA_BIND",
"tcp://#{ENV["PUMA_BIND_ALL"] ? "" : "127.0.0.1:"}#{ENV.fetch("PUMA_PORT", 3000)}",
)
#bind 'tcp://0.0.0.0:80'
#personalizzazione:plugin :acme
#acme_server_name 'example.com'
#acme_tos_agreed true
#bind 'acme://0.0.0.0:443'
# Percorso del file PID
FileUtils.mkdir_p("#{discourse_path}/tmp/pids")
pidfile ENV.fetch("PUMA_PID_PATH", "#{discourse_path}/tmp/pids/puma.pid")
# File di stato - utilizzato da pumactl
state_path "#{discourse_path}/tmp/pids/puma.state"
# Configurazione specifica per l'ambiente
if ENV["RAILS_ENV"] == "production"
# Timeout in produzione
worker_timeout 30
else
# Timeout in sviluppo
worker_timeout ENV.fetch("PUMA_TIMEOUT", 60).to_i
end
# Precarica l'applicazione
preload_app!
# Gestisci avvio e spegnimento del worker
before_fork do
Discourse.preload_rails!
Discourse.before_fork
# Controllo supervisore
supervisor_pid = ENV["PUMA_SUPERVISOR_PID"].to_i
if supervisor_pid > 0
Thread.new do
loop do
unless File.exist?("/proc/#{supervisor_pid}")
puts "Kill self supervisor is gone"
Process.kill "TERM", Process.pid
end
sleep 2
end
end
end
# Worker Sidekiq
sidekiqs = ENV["PUMA_SIDEKIQS"].to_i
if sidekiqs > 0
puts "starting #{sidekiqs} supervised sidekiqs"
require "demon/sidekiq"
Demon::Sidekiq.after_fork { DiscourseEvent.trigger(:sidekiq_fork_started) }
Demon::Sidekiq.start(sidekiqs)
if Discourse.enable_sidekiq_logging?
Signal.trap("USR1") do
# Ritarda la riapertura dei log di Sidekiq
sleep 1
Demon::Sidekiq.kill("USR2")
end
end
end
# Daemon di sincronizzazione email
if ENV["DISCOURSE_ENABLE_EMAIL_SYNC_DEMON"] == "true"
puts "starting up EmailSync demon"
Demon::EmailSync.start(1)
end
# Daemon dei plugin
DiscoursePluginRegistry.demon_processes.each do |demon_class|
puts "starting #{demon_class.prefix} demon"
demon_class.start(1)
end
# Thread di monitoraggio dei daemon
Thread.new do
loop do
begin
sleep 60
if sidekiqs > 0
Demon::Sidekiq.ensure_running
Demon::Sidekiq.heartbeat_check
Demon::Sidekiq.rss_memory_check
end
if ENV["DISCOURSE_ENABLE_EMAIL_SYNC_DEMON"] == "true"
Demon::EmailSync.ensure_running
Demon::EmailSync.check_email_sync_heartbeat
end
DiscoursePluginRegistry.demon_processes.each(&:ensure_running)
rescue => e
Rails.logger.warn(
"Error in demon processes heartbeat check: #{e}\n#{e.backtrace.join("\n")}",
)
end
end
end
# Chiudi la connessione Redis
Discourse.redis.close
end
on_worker_boot do
DiscourseEvent.trigger(:web_fork_started)
Discourse.after_fork
end
# Gestione del timeout del worker
worker_timeout 30
# Opzioni di basso livello per i worker
threads 8, 32
Per eseguire Discourse, esegui puma -C config/puma.rb
Utilizzando systemd, puoi farlo avviare all’avvio e riavviarlo in caso di errore. Ecco il file di servizio:
/etc/systemd/system/discourse.service
[Unit]
Description=Discourse with Puma Server
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
User=discourse
Group=discourse
WorkingDirectory=/var/www/discourse
# richiede l'esecuzione di `rvm 3.4.6 --default` prima che questo servizio venga eseguito
ExecStart=/usr/bin/zsh -lc 'source /home/discourse/.zshrc && /home/discourse/.rvm/gems/ruby-3.4.6/bin/puma -C config/puma.rb'
ExecReload=/usr/bin/zsh -lc 'source /home/discourse/.zshrc && /home/discourse/.rvm/gems/ruby-3.4.6/bin/pumactl restart'
# Configurazione di riavvio
Restart=always
RestartSec=5s
# Misure di sicurezza di base
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=read-only
[Install]
WantedBy=multi-user.target
Ora il server Puma ascolta su 127.0.0.1:3000. Adatta il file di configurazione di nginx da Docker:
/etc/nginx/sites-enabled/discourse.conf
# I tipi MIME aggiuntivi che desideri che nginx gestisca vanno inseriti qui
types {
text/csv csv;
#application/wasm wasm;
}
upstream discourse { server 127.0.0.1:3000; }
# inactive significa che manteniamo i dati per 1440 minuti indipendentemente dall'ultimo accesso (1 settimana)
# levels significa che è una gerarchia a 2 livelli perché possiamo avere molti file
# max_size limita la dimensione della cache
proxy_cache_path /var/nginx/cache inactive=1440m levels=1:2 keys_zone=one:10m max_size=600m;
# Aumentato rispetto al valore predefinito per accommodare cookie grandi durante i flussi oAuth2
# come in https://meta.discourse.org/t/x/74060 e grandi intestazioni CSP e Link (preload)
proxy_buffer_size 32k;
proxy_buffers 4 32k;
# Aumentato rispetto al valore predefinito per consentire un grande volume di cookie nelle intestazioni delle richieste
# Discourse stesso cerca di minimizzare la dimensione dei cookie, ma non possiamo controllare altri cookie impostati da altri strumenti sullo stesso dominio.
large_client_header_buffers 4 32k;
# tenta di preservare il protocollo, deve essere nel contesto http
map $http_x_forwarded_proto $thescheme {
default $scheme;
"~https$" https;
}
log_format log_discourse '[$time_local] "$http_host" $remote_addr "$request" "$http_user_agent" "$sent_http_x_discourse_route" $status $bytes_sent "$http_referer" $upstream_response_time $request_time "$upstream_http_x_discourse_username" "$upstream_http_x_discourse_trackview" "$upstream_http_x_queue_time" "$upstream_http_x_redis_calls" "$upstream_http_x_redis_time" "$upstream_http_x_sql_calls" "$upstream_http_x_sql_time"';
# Consenti il bypass della cache da localhost
#geo $bypass_cache {
# default 0;
# 127.0.0.1 1;
# ::1 1;
#}
limit_req_zone $binary_remote_addr zone=flood:10m rate=12r/s;
limit_req_zone $binary_remote_addr zone=bot:10m rate=200r/m;
limit_req_status 429;
limit_conn_zone $binary_remote_addr zone=connperip:10m;
limit_conn_status 429;
server {
access_log /var/log/nginx/access.log log_discourse;
#listen unix:/var/nginx/nginx.http.sock;
listen 443 ssl;
listen [::]:443 ssl;
server_name example.com;
ssl_certificate /etc/nginx/ssl/example.com.cer;
ssl_certificate_key /etc/nginx/ssl/example.com.key;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
set_real_ip_from unix:;
set_real_ip_from 127.0.0.1/32;
set_real_ip_from ::1/128;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
gzip on;
gzip_vary on;
gzip_min_length 1000;
gzip_comp_level 5;
gzip_types application/json text/css text/javascript application/x-javascript application/javascript image/svg+xml application/wasm;
gzip_proxied any;
# Decommenta e configura questa sezione per il supporto HTTPS
# NOTA: Inserisci il tuo certificato ssl nella directory principale di nginx (/etc/nginx)
#
# rewrite ^/(.*) https://enter.your.web.hostname.here/$1 permanent;
#
# listen 443 ssl;
# ssl_certificate your-hostname-cert.pem;
# ssl_certificate_key your-hostname-cert.key;
# ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
# ssl_ciphers HIGH:!aNULL:!MD5;
#
server_tokens off;
sendfile on;
keepalive_timeout 65;
# dimensione massima del caricamento dei file (mantenere aggiornata quando si cambia l'impostazione del sito corrispondente)
client_max_body_size 128m ;
# percorso alla directory pubblica di discourse
set $public /var/www/discourse/public;
# senza etag deboli non otteniamo alcun beneficio dagli etag sui contenuti compressi dinamicamente
# inoltre gli etag si basano sul file in nginx e non sull'hash sha dei dati
# usa le date, risolve il problema bene anche tra server diversi
etag off;
# previeni il download diretto dei backup
location ^~ /backups/ {
internal;
}
# bypassa lo stack rails con un economico 204 per le richieste favicon.ico
location /favicon.ico {
return 204;
access_log off;
log_not_found off;
}
location / {
root $public;
add_header ETag "";
# auth_basic on;
# auth_basic_user_file /etc/nginx/htpasswd;
location ~ ^/uploads/short-url/ {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Request-Start "t=${msec}";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $thescheme;
proxy_pass http://discourse;
break;
}
location ~ ^/(secure-media-uploads/|secure-uploads)/ {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Request-Start "t=${msec}";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $thescheme;
proxy_pass http://discourse;
break;
}
location ~* (fonts|assets|plugins|uploads)/.*\.(eot|ttf|woff|woff2|ico|otf)$ {
expires 1y;
add_header Cache-Control public,immutable;
add_header Access-Control-Allow-Origin *;
}
location = /srv/status {
access_log off;
log_not_found off;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Request-Start "t=${msec}";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $thescheme;
proxy_pass http://discourse;
break;
}
# un minimo di caching qui così non continuiamo a chiedere
# a lungo termine dovremmo probabilmente aumentare a 1 anno
location ~ ^/javascripts/ {
expires 1d;
add_header Cache-Control public,immutable;
add_header Access-Control-Allow-Origin *;
}
location ~ ^/assets/(?<asset_path>.+)$ {
expires 1y;
# asset pipeline abilita questo
brotli_static on;
gzip_static on;
add_header Cache-Control public,immutable;
# HOOK nella posizione asset (usato per l'estensibilità)
# TODO Non credo che questo break sia necessario, interrompe solo la riscrittura
break;
}
location ~ ^/plugins/ {
expires 1y;
add_header Cache-Control public,immutable;
add_header Access-Control-Allow-Origin *;
}
# cache emoji
location ~ /images/emoji/ {
expires 1y;
add_header Cache-Control public,immutable;
add_header Access-Control-Allow-Origin *;
}
location ~ ^/uploads/ {
# NOTA: è davvero fastidioso che non possiamo definire le intestazioni
# a livello superiore ed ereditarle.
#
# proxy_set_header NON eredita, per progettazione, dobbiamo ripeterlo,
# altrimenti le intestazioni non vengono impostate correttamente
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Request-Start "t=${msec}";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $thescheme;
proxy_set_header X-Sendfile-Type X-Accel-Redirect;
proxy_set_header X-Accel-Mapping $public/=/downloads/;
expires 1y;
add_header Cache-Control public,immutable;
## regole opzionali anti-hotlinking per i caricamenti
#valid_referers none blocked mysite.com *.mysite.com;
#if ($invalid_referer) { return 403; }
# CSS personalizzato
location ~ /stylesheet-cache/ {
add_header Access-Control-Allow-Origin *;
try_files $uri =404;
}
# questo ci permette di bypassare rails
location ~* \.(gif|png|jpg|jpeg|bmp|tif|tiff|ico|webp|avif)$ {
add_header Access-Control-Allow-Origin *;
try_files $uri =404;
}
# SVG ha bisogno di un'intestazione extra allegata
location ~* \.(svg)$ {
}
# miniature e immagini ottimizzate
location ~ /_?optimized/ {
add_header Access-Control-Allow-Origin *;
try_files $uri =404;
}
proxy_pass http://discourse;
break;
}
location ~ ^/admin/backups/ {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Request-Start "t=${msec}";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $thescheme;
proxy_set_header X-Sendfile-Type X-Accel-Redirect;
proxy_set_header X-Accel-Mapping $public/=/downloads/;
proxy_pass http://discourse;
break;
}
# Questo blocco grande è necessario per abilitare selettivamente
# l'accelerazione per backup, avatar, sprite e così via.
# vedi nota sulla ripetizione sopra
location ~ ^/(svg-sprite/|letter_avatar/|letter_avatar_proxy/|user_avatar|highlight-js|stylesheets|theme-javascripts|favicon/proxied|service-worker) {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Request-Start "t=${msec}";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $thescheme;
# se Set-Cookie è nella risposta nulla viene memorizzato nella cache
# questo è doppio male perché non stiamo passando last modified in
proxy_ignore_headers "Set-Cookie";
proxy_hide_header "Set-Cookie";
proxy_hide_header "X-Discourse-Username";
proxy_hide_header "X-Runtime";
# nota x-accel-redirect non può essere usato con proxy_cache
proxy_cache one;
proxy_cache_key "$scheme,$host,$request_uri";
proxy_cache_valid 200 301 302 7d;
#proxy_cache_bypass $bypass_cache;
proxy_pass http://discourse;
break;
}
# abbiamo bisogno del buffering disattivato per message bus
location /message-bus/ {
proxy_set_header X-Request-Start "t=${msec}";
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $thescheme;
proxy_http_version 1.1;
proxy_buffering off;
proxy_pass http://discourse;
break;
}
# questo significa che ogni file in public viene provato per primo
try_files $uri @discourse;
}
location /downloads/ {
internal;
alias $public/;
}
location @discourse {
limit_conn connperip 20;
limit_req zone=flood burst=12 nodelay;
limit_req zone=bot burst=100 nodelay;
proxy_set_header Host $http_host;
proxy_set_header X-Request-Start "t=${msec}";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $thescheme;
proxy_pass http://discourse;
}
}
Ora il tuo Discourse può essere accessibile da example.com:443.
manutenzione
Per accedere alla console rails, esegui semplicemente rails c come utente discourse in /var/www/discourse. Il comando discourse che si trova nella documentazione ufficiale è essenzialmente bundle exec script/discourse.
Per aggiornare Discourse, consulta #upgrade-cmd, quindi riavvia puma usando puma restart o puma phased-restart. Per la differenza, consulta puma/docs/restart.md at main · puma/puma · GitHub .