Aunque es más conveniente y seguro desplegar Discourse siguiendo la guía oficial de instalación, quiero profundizar en el contenedor y ver cómo se puede desplegar en Linux sin Docker. Quiero compartir los pasos solo para su información. Adáptelos y úselos bajo su propio riesgo.
Profundizando en cómo se ejecuta Discourse en un contenedor
Observo la salida de ./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
luego reviso /sbin/boot y /etc/service/unicorn/run y obtengo el comando principal para iniciar 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
Preparar el sistema
Para su información, uso Ubuntu 24.04 y zsh.
Siga la guía oficial de instalación de PG para instalar PostgreSQL desde el repositorio Apt de PostgreSQL. Instalé la versión 18, que funciona muy bien, aunque la instalación oficial utiliza la 15 al momento de escribir esto.
Instale redis (8.2 al momento de escribir esto, la instalación oficial usa 7.0), nginx y cree el usuario dedicado 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
Instale ImageMagick 7 (uso IMEI) y verifique la versión. La mía es:
magick --version
Version: ImageMagick 7.1.2-3 Q16-HDRI
luego, cambie de usuario (su - discourse) e instale pnpm y rvm.
curl -fsSL https://get.pnpm.io/install.sh | zsh -
curl -sSL https://get.rvm.io | bash
luego adapte y agregue la siguiente configuración a su .zshrc:
/home/discourse/.zshrc
# pnpm
export PNPM_HOME="/home/discourse/.local/share/pnpm"
case ":$PATH:" in
*":$PNPM_HOME:"*) ;;
*) export PATH="$PNPM_HOME:$PATH" ;;
esac
# fin de pnpm
alias npm='pnpm'
alias npx='pnpx'
# Agregar RVM al PATH para scripting. Asegúrese de que este sea el último cambio de variable 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
Cierre sesión y vuelva a iniciarla como discourse para que .zshrc surta efecto.
Instale node y ruby:
pnpm env use --global latest # instalará node 24.9 al momento de escribir esto. La instalación oficial usa 22
rvm get master
rvm install 3.4 # instalará ruby 3.4.6 al momento de escribir esto. La instalación oficial usa 3.3
rvm use 3.4 --default
Preparar la base de datos (y restaurar una copia de seguridad)
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;
# para restaurar la base de datos extraída de una copia de seguridad:
$ gunzip < dump.sql.gz | psql discourse
Para restaurar una copia de seguridad, también debe copiar las carpetas public y plugins.
Instalar Discourse
como usuario 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
configure 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'
Ejecute bundle / pnpm install, migración de la base de datos, precompilación de activos, etc. Esto también es cómo se actualiza Discourse y los plugins.
as user 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
No quiero usar unicorn. Heroku recomienda usar el servidor web Puma en lugar de Unicorn. Aquí está mi config/puma.rb escrito después de consultar 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"
# Cargar el logger de logstash si está habilitado
if enable_logstash_logger
require_relative "../lib/discourse_logstash_logger"
FileUtils.touch(puma_stderr_path) if !File.exist?(puma_stderr_path)
# Nota: Puede que necesite adaptar la inicialización del logger para 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
# Número de workers (procesos)
workers ENV.fetch("PUMA_WORKERS", 6).to_i
# Establecer el directorio
directory discourse_path
# Vincular a la dirección y puerto especificados
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'
#plugin :acme
#acme_server_name 'example.com'
acme_tos_agreed true
#bind 'acme://0.0.0.0:443'
# Ubicación del archivo PID
FileUtils.mkdir_p("#{discourse_path}/tmp/pids")
pidfile ENV.fetch("PUMA_PID_PATH", "#{discourse_path}/tmp/pids/puma.pid")
# Archivo de estado - utilizado por pumactl
state_path "#{discourse_path}/tmp/pids/puma.state"
# Configuración específica del entorno
if ENV["RAILS_ENV"] == "production"
# Tiempo de espera en producción
worker_timeout 30
else
# Tiempo de espera en desarrollo
worker_timeout ENV.fetch("PUMA_TIMEOUT", 60).to_i
end
# Precargar la aplicación
preload_app!
# Manejar el inicio y apagado de workers
before_fork do
Discourse.preload_rails!
Discourse.before_fork
# Verificación del supervisor
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
# Workers de 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
# Retrasar la reapertura del registro de Sidekiq
sleep 1
Demon::Sidekiq.kill("USR2")
end
end
end
# Demonio de sincronización de correo
if ENV["DISCOURSE_ENABLE_EMAIL_SYNC_DEMON"] == "true"
puts "starting up EmailSync demon"
Demon::EmailSync.start(1)
end
# Procesos demonio de plugins
DiscoursePluginRegistry.demon_processes.each do |demon_class|
puts "starting #{demon_class.prefix} demon"
demon_class.start(1)
end
# Hilo de monitoreo del demonio
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
# Cerrar conexión a Redis
Discourse.redis.close
end
on_worker_boot do
DiscourseEvent.trigger(:web_fork_started)
Discourse.after_fork
end
# Manejo de tiempo de espera del worker
worker_timeout 30
# Opciones de bajo nivel del worker
threads 8, 32
Para ejecutar Discourse, ejecute puma -C config/puma.rb
Usando systemd, puede ejecutarlo al arrancar y reiniciarlo en caso de fallo. Aquí está el archivo de servicio:
/etc/systemd/system/discourse.service
[Unit]
Description=Discourse con servidor Puma
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
User=discourse
Group=discourse
WorkingDirectory=/var/www/discourse
# requiere ejecutar `rvm 3.4.6 --default` antes de ejecutar este servicio
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'
# Configuración de reinicio
Restart=always
RestartSec=5s
# Medidas básicas de seguridad
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=read-only
[Install]
WantedBy=multi-user.target
Ahora el servidor Puma escucha en 127.0.0.1:3000. Adapte el archivo de configuración de nginx desde Docker:
/etc/nginx/sites-enabled/discourse.conf
# Tipos MIME adicionales que desea que nginx maneje van aquí
types {
text/csv csv;
#application/wasm wasm;
}
upstream discourse { server 127.0.0.1:3000; }
# inactivo significa que mantenemos las cosas durante 1440 minutos independientemente del último acceso (1 semana)
# niveles significa que es una jerarquía de 2 niveles porque podemos tener muchos archivos
# max_size limita el tamaño de la caché
proxy_cache_path /var/nginx/cache inactive=1440m levels=1:2 keys_zone=one:10m max_size=600m;
# Aumentado desde el valor predeterminado para acomodar cookies grandes durante flujos de oAuth2
# como en https://meta.discourse.org/t/x/74060 y encabezados CSP y Link (preload) grandes
proxy_buffer_size 32k;
proxy_buffers 4 32k;
# Aumentado desde el valor predeterminado para permitir un gran volumen de cookies en los encabezados de solicitud
# Discourse intenta minimizar el tamaño de las cookies, pero no podemos controlar otras cookies establecidas por otras herramientas en el mismo dominio.
large_client_header_buffers 4 32k;
# intentar preservar el protocolo, debe estar en el contexto 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"';
# Permitir omitir la caché desde 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;
# Descomente y configure esta sección para soporte HTTPS
# NOTA: Coloque su certificado ssl en su directorio principal de configuración de 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;
# tamaño máximo de carga de archivo (mantener actualizado al cambiar la configuración del sitio correspondiente)
client_max_body_size 128m ;
# ruta al directorio público de discourse
set $public /var/www/discourse/public;
# sin etags débiles no obtenemos ningún beneficio de los etags en contenido comprimido dinámicamente
# además, los etags se basan en el archivo en nginx, no en el hash de los datos
# use fechas, resuelve el problema bien incluso entre servidores
etag off;
# prevenir la descarga directa de copias de seguridad
location ^~ /backups/ {
internal;
}
# omitir la pila de rails con un 204 barato para solicitudes de 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;
}
# algo de caché mínima aquí para no seguir preguntando
# a largo plazo deberíamos aumentar probablemente a 1y
location ~ ^/javascripts/ {
expires 1d;
add_header Cache-Control public,immutable;
add_header Access-Control-Allow-Origin *;
}
location ~ ^/assets/(?<asset_path>.+)$ {
expires 1y;
# la tubería de activos habilita esto
brotli_static on;
gzip_static on;
add_header Cache-Control public,immutable;
# ENGAÑAR en la ubicación de activos (usado para extensibilidad)
# TODO No creo que este break sea necesario, solo sale de rewrite
break;
}
location ~ ^/plugins/ {
expires 1y;
add_header Cache-Control public,immutable;
add_header Access-Control-Allow-Origin *;
}
# cachear emojis
location ~ /images/emoji/ {
expires 1y;
add_header Cache-Control public,immutable;
add_header Access-Control-Allow-Origin *;
}
location ~ ^/uploads/ {
# NOTA: Es realmente molesto que no podamos definir encabezados
# en el nivel superior e heredar.
#
# proxy_set_header NO hereda, por diseño, debemos repetirlo,
# de lo contrario los encabezados no se establecen correctamente
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;
## reglas opcionales anti-hotlinking de carga
#valid_referers none blocked mysite.com *.mysite.com;
#if ($invalid_referer) { return 403; }
# CSS personalizado
location ~ /stylesheet-cache/ {
add_header Access-Control-Allow-Origin *;
try_files $uri =404;
}
# esto nos permite omitir rails
location ~* \.(gif|png|jpg|jpeg|bmp|tif|tiff|ico|webp|avif)$ {
add_header Access-Control-Allow-Origin *;
try_files $uri =404;
}
# SVG necesita un encabezado adicional adjunto
location ~* \.(svg)$ {
}
# miniaturas e imágenes optimizadas
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;
}
# Este gran bloque es necesario para que podamos habilitar selectivamente
# la aceleración para copias de seguridad, avatares, sprites, etc.
# ver nota sobre repetición arriba
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;
# si Set-Cookie está en la respuesta, nada se almacena en caché
# esto es doblemente malo porque no estamos pasando last modified
proxy_ignore_headers "Set-Cookie";
proxy_hide_header "Set-Cookie";
proxy_hide_header "X-Discourse-Username";
proxy_hide_header "X-Runtime";
# note que x-accel-redirect no se puede usar 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;
}
# necesitamos buffering desactivado para el bus de mensajes
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;
}
# esto significa que se prueba cada archivo en público primero
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;
}
}
Ahora su Discourse se puede acceder desde example.com:443.
Mantenimiento
Para acceder a la consola de rails, simplemente ejecute rails c como usuario discourse en /var/www/discourse. El comando discourse que se puede encontrar en la documentación oficial es básicamente bundle exec script/discourse.
Para actualizar Discourse, consulte #upgrade-cmd, luego reinicie puma usando puma restart o puma phased-restart. Para la diferencia, consulte puma/docs/restart.md at main · puma/puma · GitHub.