Déployer Discourse sans Docker

Bien que le déploiement de Discourse selon le guide d’installation officiel soit plus pratique et plus sûr, je souhaite explorer plus en profondeur le conteneur et voir comment il peut être déployé sur Linux sans Docker. Je souhaite partager les étapes à titre d’information. Vous les adaptez et les utilisez à vos propres risques.

Plonger dans le fonctionnement de Discourse dans un conteneur

J’examine la sortie 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

Ensuite, j’examine /sbin/boot et /etc/service/unicorn/run, et j’obtiens la commande principale pour démarrer 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

Préparer le système

Pour information, j’utilise Ubuntu 24.04 et zsh.

Suivez le guide d’installation officiel de PG pour installer PostgreSQL depuis le dépôt Apt de PostgreSQL. J’ai installé la version 18, qui fonctionne très bien, bien que l’installation officielle utilise la version 15 au moment de la rédaction.

Installez redis (8.2 au moment de la rédaction, l’installation officielle utilise 7.0), nginx et créez l’utilisateur dédié 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

Installez ImageMagick 7 (j’utilise IMEI) et vérifiez la version. La mienne est :

magick --version
Version: ImageMagick 7.1.2-3 Q16-HDRI

Ensuite, changez d’utilisateur (su - discourse) et installez pnpm et rvm.

curl -fsSL https://get.pnpm.io/install.sh | zsh -
curl -sSL https://get.rvm.io | bash

Ensuite, adaptez et ajoutez la configuration suivante à votre .zshrc :

/home/discourse/.zshrc

# pnpm
export PNPM_HOME="/home/discourse/.local/share/pnpm"
case ":$PATH:" in
  *":$PNPM_HOME:"*) ;;
  *) export PATH="$PNPM_HOME:$PATH" ;;
esac
# pnpm fin
alias npm='pnpm'
alias npx='pnpx'

# Ajouter RVM au PATH pour le scripting. Assurez-vous que ceci est le dernier changement 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

Déconnectez-vous et reconnectez-vous en tant que discourse pour que .zshrc prenne effet.

Installez Node et Ruby :

pnpm env use --global latest # installera Node 24.9 au moment de la rédaction. L'installation officielle utilise 22
rvm get master
rvm install 3.4 # installera Ruby 3.4.6 au moment de la rédaction. L'installation officielle utilise 3.3
rvm use 3.4 --default

Préparer la base de données (et restaurer une sauvegarde)

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;
# Pour restaurer une base de données extraite d'une sauvegarde :
$ gunzip < dump.sql.gz | psql discourse

Pour restaurer une sauvegarde, vous devez également copier les dossiers public et plugins.

Installer Discourse

Je me suis référé à discourse_docker/templates/web.template.yml at 20e33fbfd98d3b8d9c57f7a111beff8aa51a5b98 · discourse/discourse_docker · GitHub

En tant qu’utilisateur 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

Configurez 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'

Effectuez les opérations d’installation de bundle/pnpm, de migration de base de données et de précompilation des assets. C’est aussi ainsi que vous mettez à jour Discourse et les plugins.

En tant qu’utilisateur 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

Je ne souhaite pas utiliser unicorn. Heroku recommande d’utiliser le serveur web Puma plutôt qu’Unicorn. Voici mon fichier config/puma.rb écrit après consultation de 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"

# Charger le logger logstash si activé
if enable_logstash_logger
  require_relative "../lib/discourse_logstash_logger"
  FileUtils.touch(puma_stderr_path) if !File.exist?(puma_stderr_path)
  # Remarque : Vous devrez peut-être adapter l'initialisation du logger pour 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

# Nombre de workers (processus)
workers ENV.fetch("PUMA_WORKERS", 6).to_i

# Définir le répertoire
directory discourse_path

# Se lier à l'adresse et au port spécifiés
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'

# Emplacement du fichier PID
FileUtils.mkdir_p("#{discourse_path}/tmp/pids")
pidfile ENV.fetch("PUMA_PID_PATH", "#{discourse_path}/tmp/pids/puma.pid")

# Fichier d'état - utilisé par pumactl
state_path "#{discourse_path}/tmp/pids/puma.state"

# Configuration spécifique à l'environnement
if ENV["RAILS_ENV"] == "production"
  # Timeout en production
  worker_timeout 30
else
  # Timeout en développement
  worker_timeout ENV.fetch("PUMA_TIMEOUT", 60).to_i
end

# Précharger l'application
preload_app!

# Gérer le démarrage et l'arrêt des workers
before_fork do
  Discourse.preload_rails!
  Discourse.before_fork

  # Vérification du superviseur
  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 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
        # Délai avant la réouverture du journal Sidekiq
        sleep 1
        Demon::Sidekiq.kill("USR2")
      end
    end
  end

  # Démon de synchronisation des e-mails
  if ENV["DISCOURSE_ENABLE_EMAIL_SYNC_DEMON"] == "true"
    puts "starting up EmailSync demon"
    Demon::EmailSync.start(1)
  end

  # Démon des plugins
  DiscoursePluginRegistry.demon_processes.each do |demon_class|
    puts "starting #{demon_class.prefix} demon"
    demon_class.start(1)
  end

  # Thread de surveillance du démon
  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

  # Fermer la connexion Redis
  Discourse.redis.close
end

on_worker_boot do
  DiscourseEvent.trigger(:web_fork_started)
  Discourse.after_fork
end

# Gestion du timeout des workers
worker_timeout 30

# Options de bas niveau pour les workers
threads 8, 32

Pour exécuter Discourse, lancez puma -C config/puma.rb.

En utilisant systemd, vous pouvez le démarrer au démarrage et le redémarrer en cas d’échec. Voici le fichier de service :

/etc/systemd/system/discourse.service
[Unit]
Description=Discourse avec le serveur Puma
After=network.target postgresql.service
Requires=postgresql.service

[Service]
Type=simple
User=discourse
Group=discourse
WorkingDirectory=/var/www/discourse
# Nécessite d'exécuter `rvm 3.4.6 --default` avant le lancement de ce service
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'

# Configuration du redémarrage
Restart=always
RestartSec=5s

# Mesures de sécurité de base
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=read-only

[Install]
WantedBy=multi-user.target

Maintenant, le serveur Puma écoute sur 127.0.0.1:3000. Adaptez le fichier de configuration nginx depuis Docker :

/etc/nginx/sites-enabled/discourse.conf
# Types MIME supplémentaires que vous souhaitez que nginx gère, ajoutez-les ici
types {
    text/csv csv;
    #application/wasm wasm;
}

upstream discourse { server 127.0.0.1:3000; }

# inactive signifie que nous conservons les éléments pendant 1440 minutes indépendamment du dernier accès (1 semaine)
# levels signifie qu'il s'agit d'une hiérarchie de 2 niveaux car nous pouvons avoir beaucoup de fichiers
# max_size limite la taille du cache
proxy_cache_path /var/nginx/cache inactive=1440m levels=1:2 keys_zone=one:10m max_size=600m;

# Augmenté par rapport à la valeur par défaut pour accommoder les gros cookies lors des flux oAuth2
# comme dans https://meta.discourse.org/t/x/74060 et les gros en-têtes CSP et Link (preload)
proxy_buffer_size 32k;
proxy_buffers 4 32k;

# Augmenté par rapport à la valeur par défaut pour permettre un grand volume de cookies dans les en-têtes de requête
# Discourse lui-même tente de minimiser la taille des cookies, mais nous ne pouvons pas contrôler les autres cookies définis par d'autres outils sur le même domaine.
large_client_header_buffers 4 32k;

# Tenter de préserver le protocole, doit être dans le contexte 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"';

# Autoriser la bypass du cache depuis 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;

  # Décommenter et configurer cette section pour la prise en charge HTTPS
  # NOTE : Placez votre certificat SSL dans votre répertoire de configuration nginx principal (/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;

  # taille maximale de téléchargement de fichier (maintenez à jour lors de la modification du paramètre de site correspondant)
  client_max_body_size 128m ;


  # chemin vers le répertoire public de Discourse
  set $public /var/www/discourse/public;

  # sans les weak etags, nous ne tirons aucun bénéfice des etags sur le contenu dynamiquement compressé
  # de plus, les etags sont basés sur le fichier dans nginx et non sur le hachage des données
  # utilisez les dates, cela résout le problème correctement même entre serveurs
  etag off;

  # empêcher le téléchargement direct des sauvegardes
  location ^~ /backups/ {
    internal;
  }

  # bypass de la pile Rails avec un 204 peu coûteux pour les requêtes 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 minimum de mise en cache ici pour éviter de continuer à demander
    # à long terme, nous devrions probablement augmenter à 1 an
    location ~ ^/javascripts/ {
      expires 1d;
      add_header Cache-Control public,immutable;
      add_header Access-Control-Allow-Origin *;
    }

    location ~ ^/assets/(?<asset_path>.+)$ {
      expires 1y;
      # le pipeline d'assets active ceci
      brotli_static on;
      gzip_static on;
      add_header Cache-Control public,immutable;
      # HOOK dans l'emplacement des assets (utilisé pour l'extensibilité)
      # TODO Je ne pense pas que ce break soit nécessaire, il sort simplement de rewrite
      break;
    }

    location ~ ^/plugins/ {
      expires 1y;
      add_header Cache-Control public,immutable;
      add_header Access-Control-Allow-Origin *;
    }

    # mettre en cache les emojis
    location ~ /images/emoji/ {
      expires 1y;
      add_header Cache-Control public,immutable;
      add_header Access-Control-Allow-Origin *;
    }

    location ~ ^/uploads/ {

      # NOTE : il est vraiment ennuyeux que nous ne puissions pas simplement définir les en-têtes
      # au niveau supérieur et les hériter.
      #
      # proxy_set_header n'hérite PAS, par conception, nous devons le répéter,
      # sinon les en-têtes ne sont pas définis correctement
      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;

      ## règles optionnelles de protection contre le hotlinking des téléchargements
      #valid_referers none blocked mysite.com *.mysite.com;
      #if ($invalid_referer) { return 403; }

      # CSS personnalisé
      location ~ /stylesheet-cache/ {
          add_header Access-Control-Allow-Origin *;
          try_files $uri =404;
      }
      # cela nous permet de bypass Rails
      location ~* \.(gif|png|jpg|jpeg|bmp|tif|tiff|ico|webp|avif)$ {
          add_header Access-Control-Allow-Origin *;
          try_files $uri =404;
      }
      # SVG nécessite un en-tête supplémentaire attaché
      location ~* \.(svg)$ {
      }
      # miniatures et images optimisées
      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;
    }

    # Ce gros bloc est nécessaire pour que nous puissions activer sélectivement
    # l'accélération pour les sauvegardes, les avatars, les sprites, etc.
    # voir la note sur la répétition ci-dessus
    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 dans la réponse, rien n'est mis en cache
      # c'est doublement mauvais car nous ne transmettons pas last modified
      proxy_ignore_headers "Set-Cookie";
      proxy_hide_header "Set-Cookie";
      proxy_hide_header "X-Discourse-Username";
      proxy_hide_header "X-Runtime";

      # note : x-accel-redirect ne peut pas être utilisé avec 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;
    }

    # nous devons désactiver le buffering pour le 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;
    }

    # cela signifie que chaque fichier dans public est essayé en premier
    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;
  }

}

Maintenant, votre Discourse est accessible depuis example.com:443.

Maintenance

Pour accéder à la console Rails, exécutez simplement rails c en tant qu’utilisateur discourse dans /var/www/discourse. La commande discourse que l’on trouve dans la documentation officielle est essentiellement bundle exec script/discourse.

Pour mettre à jour Discourse, consultez #upgrade-cmd, puis redémarrez Puma en utilisant soit puma restart soit puma phased-restart. Pour la différence, consultez puma/docs/restart.md at main · puma/puma · GitHub.

8 « J'aime »

Faudrait-il déplacer ceci vers Community wiki > Sysadmins, peut-être ?

Salut. Merci pour le partage.

J’écris un script pour l’installer sur Debian 12 dans un conteneur LXC. Il est presque terminé et fonctionne bien. Je le publierai quand il sera prêt.

J’ai réussi à afficher la première page pour l’enregistrement de l’administrateur. Mais l’e-mail de confirmation est envoyé à discourse@myhostname, et à myhostname comme serveur smtp, donc c’est absurde. Les variables dans .bashrc (ou .zshrc), ni dans discourse.conf ne sont prises en compte pour l’envoi de l’e-mail. L’adresse e-mail du développeur est correcte mais tous les autres paramètres sont faux et je n’ai pas pu les modifier. Auriez-vous une idée pour savoir comment y parvenir ?

1 « J'aime »

Selon le code

SMTP doit être configuré dans config/discourse.conf.
Pour moi, j’ai ces lignes dedans :

smtp_user_name = '...com'
smtp_address = '...com'
smtp_port = '587'
smtp_password = '...'
smtp_domain = '...com'
notification_email = 'noreply@....com'

Avez-vous vérifié les logs ? Pour cette installation personnalisée, le log se trouve dans production.log, production.log, puma.stdout.log sous le répertoire log.

Merci beaucoup pour votre réponse.

J’ai déjà ces paramètres dans config/discourse.conf. J’ai fait exactement ce que vous avez écrit, sauf avec zsh.
Mes logs ne parlent pas de smtp, et le seul extrait (production.log) est

Started GET “/” for 192.168.1.14 at 2025-09-15 17:12:51 +0000
Processing by FinishInstallationController#index as HTML
Rendered layout layouts/finish_installation.html.erb (Duration: 57.8ms | GC: 1.0ms)
Completed 200 OK in 164ms (Views: 61.7ms | ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 8.7ms)
Started GET “/finish-installation/register” for 192.168.1.14 at 2025-09-15 17:12:53 +0000
Processing by FinishInstallationController#register as HTML
Rendered layout layouts/finish_installation.html.erb (Duration: 63.8ms | GC: 1.5ms)
Completed 200 OK in 166ms (Views: 68.0ms | ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 5.4ms)
Started POST “/finish-installation/register” for 192.168.1.14 at 2025-09-15 17:12:54 +0000
Processing by FinishInstallationController#register as HTML
Parameters: {“authenticity_token”=>“U9_0mqt8iE5Y_jdNSV5uZxOgz9rspJbEsohs0jU8QTOPaOXdyaG-oLSFYtn9dQ2-mdHYvCzjFsRaqzp6YlNzbQ”, “email”=>“webmaster@domain.app”, “username”=>“ioio”, “password”=>“[FILTERED]”, “commit”=>“Register”}
Redirected to http://myhostname/finish-installation/confirm-email
Completed 302 Found in 140ms (ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 4.4ms)
Started GET “/finish-installation/confirm-email” for 192.168.1.14 at 2025-09-15 17:12:54 +0000
Processing by FinishInstallationController#confirm_email as HTML
Rendered layout layouts/finish_installation.html.erb (Duration: 62.1ms | GC: 1.5ms)

Les journaux de mon serveur de messagerie ne montrent aucune entrée du serveur discourse (cela fonctionne pour tous mes autres serveurs (tous les conteneurs lxc). Je peux envoyer un e-mail avec succès via la commande terminal mail.

avec puma -C config/puma.rb :

Use ‘before_worker_boot’, ‘on_worker_boot’ is deprecated and will be removed in v8
[498] Puma starting in cluster mode…
[498] * Puma version: 7.0.0 (“Romantic Warrior”)
[498] * Ruby version: ruby 3.3.9 (2025-07-24 revision f5c772fc7c) [x86_64-linux]
[498] * Min threads: 8
[498] * Max threads: 32
[498] * Environment: production
[498] * Master PID: 498
[498] * Workers: 8
[498] * Restarts: (✔) hot (✖) phased (✖) refork
[498] * Preloading application
[498] * Listening on http://127.0.0.1:3000
[498] ! WARNING: Detected 2 Thread(s) started in app boot:
[498] ! #<Thread:0x00007f43cec88b38 /home/discourse/.rvm/gems/ruby-3.3.9/gems/message_bus-4.4.1/lib/message_bus.rb:738 sleep> - /home/discourse/.rvm/gems/ruby-3.3.9/gems/redis-client-0.25.2/lib/redis_client/ruby_connection/buffered_io.rb:213:in `wait_readable'
[498] ! #<Thread:0x00007f43cec887f0 /home/discourse/.rvm/gems/ruby-3.3.9/gems/message_bus-4.4.1/lib/message_bus/timer_thread.rb:38 sleep> - /home/discourse/.rvm/gems/ruby-3.3.9/gems/message_bus-4.4.1/lib/message_bus/timer_thread.rb:130:in `sleep’
[498] Use Ctrl-C to stop

mon discourse.conf :

max_data_explorer_api_req_mode = ‘none’
max_user_api_reqs_per_day = ‘28800’
hostname = ‘xxxxxxxxxxxxxxxxx.xxxx.app’
redis_host = ‘localhost’
smtp_user_name = ‘xxxxx@xxxxx.app’
db_password = ‘password’
smtp_address = ‘mail.xxxxx.app’
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 = ‘webmaster@xxx.app’
max_user_api_reqs_per_minute = ‘200’
maxmind_license_key = ‘’
smtp_port = ‘465’
maxmind_account_id = ‘50’
smtp_password = ‘xxxxxx’
max_reqs_per_ip_per_minute = ‘20000’
notification_email = ‘no-reply-discourse@xxx.app’
db_host = ‘localhost’
enable_cors = ‘true’
db_port = ‘’
max_reqs_per_ip_mode = ‘none’
smtp_domain = ‘xxx.app’
max_admin_api_reqs_per_minute = ‘600’

.bashrc est comme votre .zhrc
modifier les entrées de developer_emails ou db_password fonctionne (le bon e-mail est affiché sur la page d’inscription de l’administrateur du site Web), mais d’autres paramètres smtp sont ignorés.

Votre fichier config/puma.rb contient des erreurs (autour de la ligne avec le port 3000). Pourriez-vous s’il vous plaît le fournir à nouveau ?

J’ai enregistré l’administrateur avec rails c et après cela, j’obtiens la page “Oups…” sur la page de démarrage. Aucune page n’est correctement rendue.

Aidez-moi s’il vous plaît.

@lion , essayez celui-ci :

# frozen_string_literal: true

require "fileutils"

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"

# Charger le logger logstash s'il est activé
if enable_logstash_logger
  require_relative "../lib/discourse_logstash_logger"
  FileUtils.touch(puma_stderr_path) if !File.exist?(puma_stderr_path)
  # Remarque : vous devrez peut-être adapter l'initialisation du logger pour 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

# Nombre de workers (processus)
workers ENV.fetch("PUMA_WORKERS", 8).to_i

# Définir le répertoire
directory discourse_path

# Se lier à l'adresse et au port spécifiés
bind ENV.fetch("PUMA_BIND", "tcp://#{ENV['PUMA_BIND_ALL'] ? '' : '127.0.0.1:'}3000")

# Emplacement du fichier PID
FileUtils.mkdir_p("#{discourse_path}/tmp/pids")
pidfile ENV.fetch("PUMA_PID_PATH", "#{discourse_path}/tmp/pids/puma.pid")

# Fichier d'état - utilisé par pumactl
state_path "#{discourse_path}/tmp/pids/puma.state"

# Configuration spécifique à l'environnement
if ENV["RAILS_ENV"] == "production"
  # Délai d'attente en production
  worker_timeout 30
else
  # Délai d'attente en développement
  worker_timeout ENV.fetch("PUMA_TIMEOUT", 60).to_i
end

# Précharger l'application
preload_app!

# Gérer le démarrage et l'arrêt des workers
before_fork do
  Discourse.preload_rails!
  Discourse.before_fork

  # Vérification du superviseur
  supervisor_pid = ENV["PUMA_SUPERVISOR_PID"].to_i
  if supervisor_pid > 0
    Thread.new do
      loop do
        unless File.exist?("/proc/#{supervisor_pid}")
          puts "Le superviseur de mise à mort s'est arrêté"
          Process.kill "TERM", Process.pid
        end
        sleep 2
      end
    end
  end

  # Workers Sidekiq
  sidekiqs = ENV["PUMA_SIDEKIQS"].to_i
  if sidekiqs > 0
    puts "démarrage de #{sidekiqs} sidekiqs supervisés"

    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
        # Retarder la réouverture des logs Sidekiq
        sleep 1
        Demon::Sidekiq.kill("USR2")
      end
    end
  end

  # Démon de synchronisation des e-mails
  if ENV["DISCOURSE_ENABLE_EMAIL_SYNC_DEMON"] == "true"
    puts "démarrage du démon EmailSync"
    Demon::EmailSync.start(1)
  end

  # Démons de plugins
  DiscoursePluginRegistry.demon_processes.each do |demon_class|
    puts "démarrage du démon #{demon_class.prefix}"
    demon_class.start(1)
  end

  # Thread de surveillance des démons
  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("Erreur lors de la vérification du battement de cœur des démons : #{e}\n#{e.backtrace.join("\n")}")
      end
    end
  end

  # Fermer la connexion Redis
  Discourse.redis.close
end

on_worker_boot do
  DiscourseEvent.trigger(:web_fork_started)
  Discourse.after_fork
end

# Gestion du délai d'attente des workers
worker_timeout 30

# Options de workers de bas niveau
threads 8, 32

Merci. J’ai oublié de dire que c’est derrière haproxy pour plusieurs serveurs.

  1. J’obtiens du “contenu mixte” depuis la console du navigateur. Que puis-je changer dans le fichier nginx ?
  2. J’obtiens ce log concernant magick au démarrage de la commande puma. Il est pourtant installé au début.

==
/var/www/discourse/log/puma.stderr.log

=== démarrage de puma : 2025-09-19 01:40:45 +0200 ===
OID inconnu 16720 : impossible de reconnaître le type de ‘embeddings’. Il sera traité comme une chaîne de caractères.
#<Thread:0x00007fc59a2f5a78 /var/www/discourse/lib/discourse.rb:1190 run> terminé avec une exception (report_on_exception est vrai) :
/var/www/discourse/lib/letter_avatar.rb:112:in ``’ : Aucun fichier ou dossier de ce type - magick (Errno::ENOENT)
from /var/www/discourse/lib/letter_avatar.rb:112:in image_magick_version' from /var/www/discourse/lib/discourse.rb:1190:in block in preload_rails!’
J’ai lu que cela pouvait être une cause de non-affichage des pages.

Forcez-vous le SSL dans les paramètres de Discourse ?

Pouvez-vous exécuter magick --version en tant qu’utilisateur discourse ? Pour moi, la sortie est :
Version : ImageMagick 7.1.1-45 Q16-HDRI x86_64 3cbce5696:20250308 https://imagemagick.org
Copyright : (C) 1999 ImageMagick Studio LLC
License : ImageMagick | License
Fonctionnalités : Cipher DPC HDRI Modules OpenMP(4.5)
Délégués (intégrés) : bzlib cairo djvu fftw fontconfig freetype gslib gvc heic jbig jng jp2 jpeg jxl lcms lqr ltdl lzma openexr pangocairo png ps raqm raw rsvg tiff webp wmf x xml zip zlib zstd
Compilateur : gcc (13.3)

Oui, c’est exactement ce que je pensais.

J’ai trouvé beaucoup de dépendances non satisfaites lors de l’installation “manuelle”. J’écris un playbook Ansible pour pouvoir déployer Discourse sur plusieurs environnements.

Mes systèmes sont almalinux/redhat mais je partagerai ce que j’ai trouvé lorsque je considérerai que c’est presque terminé.

1 « J'aime »

Dans haproxy.cfg, le port 443 est avec crt, donc crypté, redirigé vers le conteneur via 8080 :

backend BACKEND_XXX
option forwardfor
http-request set-header X-Forwarded-Port %[dst_port]
http-request add-header X-Forwarded-Proto https if { ssl_fc }
server xxx 192.168.1.48:8080 weight 1 #adding “check ssl verify none” if ssl on nginx discourse

  1. Dans nginx/…/discourse.conf, j’ai copié la version de ce post et modifié le port, le servername et l’emplacement des fichiers de certificat.
    => PB1 : contenu et logs mélangés avec le même problème de magick
    => PB2 : sur la page “/confirm-email”, j’obtiens une erreur “site introuvable” du navigateur.

  2. Ensuite, j’ai modifié le fichier en supprimant le ssl sur le port et en commentant toutes les lignes avec “$thescheme”, pour ne crypter que dans haproxy.
    => Erreur 502 sur la première page, mêmes logs avec magick

  3. Ensuite, j’ai installé magick depuis la source (7.1.2.3), j’ai ajouté le dossier binaire à PATH dans .bashrc pour les utilisateurs root et discourse. Cela n’a pas fonctionné avec “apt install” (aucun effet comme celui-ci).
    => L’erreur 502 a disparu, toujours du contenu mixte et “site introuvable” du navigateur.
    => Les logs indiquent toujours que magick est introuvable, mais différemment.

== /var/www/discourse/log/puma.stderr.log ==
=== puma startup: 2025-09-19 12:06:05 +0200 ===
unknown OID 16720: failed to recognize type of ‘embeddings’. It will be treated as String.
#<Thread:0x00007f611a21c0f0 /var/www/discourse/lib/discourse.rb:1190 run> terminated with exception (report_on_exception is true):
/var/www/discourse/lib/letter_avatar.rb:112:in ``‘: No such file or directory - magick (Errno::ENOENT)
from /var/www/discourse/lib/letter_avatar.rb:112:in image_magick_version' from /var/www/discourse/lib/discourse.rb:1190:in block in preload_rails!’
== /var/www/discourse/log/puma.stdout.log ==
[8967] WARNING hook before_fork failed with exception (Errno::ENOENT) No such file or directory - magick

Conclusion :
Comment rendre magick trouvable par puma ?
Comment désactiver le ssl dans nginx à cause de haproxy ?

J’ai réussi à le faire fonctionner comme décrit dans mon premier message, mais ici sans ssl sur nginx en copiant le nginx.config.sample en changeant juste le nom d’hôte et le port.
Donc, que ce soit avec ssl ou sans sur nginx, j’obtiens la première page pour l’enregistrement de l’administrateur jusqu’à la page de renvoi d’e-mail, mais LES DEUX :

  1. affichent du « contenu mixte » (pour les fichiers images par exemple),
  2. les pages autres que l’enregistrement de l’administrateur affichent « Ooops… »
  3. aucun e-mail de confirmation n’est envoyé
  4. magick est toujours introuvable

Quelque part dans l’un de vos messages, vous mentionnez un conteneur.

Utilisez-vous des conteneurs pour l’une des parties concernées ?

Aujourd’hui, je veux finaliser mon déploiement dans l’environnement de préproduction et peut-être que j’aurai plus d’informations à partager.

Pareil que toi :unamused_face:

  • Ne pas envoyer d’e-mail de confirmation (j’utilise mailtrap, je suis sûr que ça marche).
  • Créé un utilisateur admin avec rake
  • Oups sur / après ça…
  • Vu aussi un problème de magick et magick est installé (je suis à peu près sûr qu’il manque un rubygem…)

Je le déploie en utilisant docker puis je compare. Je déteste docker :expressionless_face: mais je vais perdre beaucoup de temps précieux…

Je connais à peine Docker (voire pas du tout !) et je n’ai pas eu besoin de le connaître pour faire fonctionner un forum Discourse en production. Il suffit de suivre les instructions prises en charge pour obtenir ce dont vous avez besoin.

Oui, je vous crois. Mais j’ai besoin de savoir ce que je déploie et comment cela fonctionne.

C’est la meilleure façon de trouver et de résoudre les problèmes :squinting_face_with_tongue:

Parce que vous aurez des problèmes, toujours, peut-être pas maintenant, mais je suis sûr que le futur vous en trouverez.

Quoi qu’il en soit, comme je n’obtiendrai pas de support en utilisant une méthode d’installation alternative, je suivrai les instructions officielles et j’essaierai d’aider les autres :winking_face_with_tongue:

J’ai également passé beaucoup de temps sur ces problèmes.
Concernant Docker, regardez ce problème sur Docker (une erreur ou une porte dérobée ?..), corrigé pour le moment : https://youtu.be/dTqxNc1MVLE
C’est l’une des raisons pour lesquelles je n’utiliserais pas Docker.
J’utilise des conteneurs lxd (lxc), et je m’en trouve bien. Je vais d’abord installer Docker sur un conteneur lxc, puis exporter la base de données plus tard lorsqu’il sera possible de l’installer sans Docker sur un lxc.

Eh bien, je ne force pas Discourse à utiliser le SSL car je veux que mon haproxy s’en charge, car le passtrough d’haproxy ne fonctionne pas avec le protocole HTTP pour rediriger les requêtes GET, et je gère plusieurs sites Web, j’ai donc besoin du protocole HTTP dans haproxy, ce qui nécessite une gestion SSL côté haproxy. Je voudrais éviter une double passerelle SSL.
Donc, haproxy (sur un lxc) écoute sur le port 443 : redirige vers le port 8080 (sans SSL) vers mon conteneur Discourse (lxc).
Ce qui est curieux, c’est que l’échantillon de configuration nginx donné dans le dossier Discourse est configuré sans SSL et sur le port 80, donc cela devrait bien fonctionner, mais j’obtiens les problèmes mentionnés ci-dessus.

Vous devez le faire pour indiquer à Discourse de n’envoyer que des liens https. Il ne s’agit pas de modifier les redirections, mais de la nécessité de celles-ci.

Vous devez activer le paramètre « forcer https ».

HTTPS : Oui, mais dites-moi comment l’activer ?

EXCELLENTE NOUVELLE ! J’AI RÉSOLU LES PROBLÈMES !!! La source était le programme magick. Après une installation correcte de magick à partir de la version source 7 (n’installez pas imagemagick avec apt, c’est la version 6), mon site Web discourse affiche toutes les pages correctement ! Les e-mails, l’enregistrement des administrateurs, etc. fonctionnent ! Une fois le problème de « contenu mixte » résolu, je publierai mon script pour l’installation sur un conteneur LXD-LXC (Debian 12) derrière haproxy, sur un autre conteneur LXD-LXC.

1 « J'aime »