Развертывание Discourse без Docker

Хотя развертывание Discourse по официальной инструкции установки удобнее и безопаснее, я хочу заглянуть глубже в контейнер и посмотреть, как его можно развернуть в Linux без Docker. Я хочу поделиться этими шагами просто для информации. Адаптируйте их и используйте на свой страх и риск.

Погружение в работу Discourse в контейнере

Я изучаю вывод команды ./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

Затем смотрю на /sbin/boot и /etc/service/unicorn/run, чтобы получить основную команду для запуска 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

Подготовка системы

К сведению, я использую Ubuntu 24.04 и zsh.

Следуйте официальной инструкции по установке PostgreSQL, чтобы установить PostgreSQL из репозитория Apt. Я установил версию 18, которая работает отлично, хотя на момент написания официальной установки используется версия 15.

Установите redis (версия 8.2 на момент написания, в официальной установке используется 7.0), nginx и создайте выделенного пользователя 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

Установите ImageMagick 7 (я использую IMEI) и проверьте версию. У меня:

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

Затем переключитесь на пользователя (su - discourse) и установите pnpm, rvm.

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

Затем адаптируйте и добавьте следующую конфигурацию в ваш .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 end
alias npm='pnpm'
alias npx='pnpx'

# Добавьте RVM в PATH для скриптов. Убедитесь, что это последнее изменение переменной 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

Выйдите из системы и войдите снова как пользователь discourse, чтобы изменения в .zshrc вступили в силу.

Установите node и ruby:

pnpm env use --global latest # на момент написания установит node 24.9. В официальной установке используется 22
rvm get master
rvm install 3.4 # на момент написания установит ruby 3.4.6. В официальной установке используется 3.3
rvm use 3.4 --default

Подготовка базы данных (и восстановление резервной копии)

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;
# для восстановления базы данных, извлеченной из резервной копии:
$ gunzip < dump.sql.gz | psql discourse

Для восстановления резервной копии также необходимо скопировать папки public и plugins.

Установка Discourse

Я consulted discourse_docker/templates/web.template.yml at 20e33fbfd98d3b8d9c57f7a111beff8aa51a5b98 · discourse/discourse_docker · GitHub

как пользователь 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

Настройте файл 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'

Выполните установку bundle / pnpm, миграцию базы данных, предварительную компиляцию ассетов и т.д. Это также способ обновления Discourse и плагинов.

как пользователь 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

Я не хочу использовать unicorn. Heroku рекомендует использовать веб-сервер Puma вместо Unicorn. Вот мой файл config/puma.rb, написанный после изучения 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"

# Загрузите логгер logstash, если он включен
if enable_logstash_logger
  require_relative "../lib/discourse_logstash_logger"
  FileUtils.touch(puma_stderr_path) if !File.exist?(puma_stderr_path)
  # Примечание: возможно, вам потребуется адаптировать инициализацию логгера для 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

# Количество рабочих процессов
workers ENV.fetch("PUMA_WORKERS", 6).to_i

# Установите директорию
directory discourse_path

# Привяжитесь к указанному адресу и порту
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'

# Расположение PID-файла
FileUtils.mkdir_p("#{discourse_path}/tmp/pids")
pidfile ENV.fetch("PUMA_PID_PATH", "#{discourse_path}/tmp/pids/puma.pid")

# Файл состояния - используется pumactl
state_path "#{discourse_path}/tmp/pids/puma.state"

# Конфигурация, специфичная для окружения
if ENV["RAILS_ENV"] == "production"
  # Таймаут для продакшена
  worker_timeout 30
else
  # Таймаут для разработки
  worker_timeout ENV.fetch("PUMA_TIMEOUT", 60).to_i
end

# Предварительная загрузка приложения
preload_app!

# Обработка запуска и остановки рабочих процессов
before_fork do
  Discourse.preload_rails!
  Discourse.before_fork

  # Проверка супервизора
  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

  # Рабочие процессы 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
        # Задержка повторного открытия логов Sidekiq
        sleep 1
        Demon::Sidekiq.kill("USR2")
      end
    end
  end

  # Демон синхронизации почты
  if ENV["DISCOURSE_ENABLE_EMAIL_SYNC_DEMON"] == "true"
    puts "starting up EmailSync demon"
    Demon::EmailSync.start(1)
  end

  # Демоны плагинов
  DiscoursePluginRegistry.demon_processes.each do |demon_class|
    puts "starting #{demon_class.prefix} demon"
    demon_class.start(1)
  end

  # Поток мониторинга демонов
  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

  # Закройте соединение с Redis
  Discourse.redis.close
end

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

# Обработка таймаута рабочего процесса
worker_timeout 30

# Низкоуровневые опции рабочих процессов
threads 8, 32

Для запуска Discourse выполните puma -C config/puma.rb.

Используя systemd, вы можете настроить запуск при загрузке и перезапуск при сбоях. Вот файл службы:

/etc/systemd/system/discourse.service
[Unit]
Description=Discourse с сервером Puma
After=network.target postgresql.service
Requires=postgresql.service

[Service]
Type=simple
User=discourse
Group=discourse
WorkingDirectory=/var/www/discourse
# перед запуском этой службы необходимо выполнить `rvm 3.4.6 --default`
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'

# Конфигурация перезапуска
Restart=always
RestartSec=5s

# Базовые меры безопасности
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=read-only

[Install]
WantedBy=multi-user.target

Теперь сервер Puma слушает адрес 127.0.0.1:3000. Адаптируйте файл конфигурации nginx из Docker:

/etc/nginx/sites-enabled/discourse.conf
# Дополнительные типы MIME, которые вы хотите обрабатывать в nginx, добавьте сюда
types {
    text/csv csv;
    #application/wasm wasm;
}

upstream discourse { server 127.0.0.1:3000; }

# inactive означает, что мы храним файлы 1440 минут независимо от последнего доступа (1 неделя)
# levels означает двухуровневую иерархию, так как у нас может быть много файлов
# max_size ограничивает размер кэша
proxy_cache_path /var/nginx/cache inactive=1440m levels=1:2 keys_zone=one:10m max_size=600m;

# Увеличено по сравнению со значением по умолчанию для accommodating больших cookies во время потоков oAuth2,
# таких как в https://meta.discourse.org/t/x/74060, и больших заголовков CSP и Link (preload)
proxy_buffer_size 32k;
proxy_buffers 4 32k;

# Увеличено по сравнению со значением по умолчанию для accommodating большого объема cookies в заголовках запроса
# Сам Discourse пытается минимизировать размер cookies, но мы не можем контролировать другие cookies, установленные другими инструментами в том же домене.
large_client_header_buffers 4 32k;

# Попытка сохранить протокол, должно быть в контексте 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"';

# Разрешить обход кэша с 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;

  # Раскомментируйте и настройте этот раздел для поддержки HTTPS
  # ПРИМЕЧАНИЕ: Поместите ваш SSL-сертификат в основную директорию конфигурации 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;

  # максимальный размер загружаемого файла (обновляйте при изменении соответствующего параметра сайта)
  client_max_body_size 128m ;


  # путь к публичной директории Discourse
  set $public /var/www/discourse/public;

  # без слабых etags мы не получаем никакой пользы от etags на динамически сжатом контенте
  # кроме того, etags основаны на файле в nginx, а не на хэше данных
  # используйте даты, это решает проблему даже между серверами
  etag off;

  # предотвращение прямой загрузки резервных копий
  location ^~ /backups/ {
    internal;
  }

  # обход стека Rails с дешевым 204 для запросов 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;
    }

    # минимальное кэширование здесь, чтобы мы не спрашивали постоянно
    # в долгосрочной перспективе, вероятно, следует увеличить до 1 года
    location ~ ^/javascripts/ {
      expires 1d;
      add_header Cache-Control public,immutable;
      add_header Access-Control-Allow-Origin *;
    }

    location ~ ^/assets/(?<asset_path>.+)$ {
      expires 1y;
      # конвейер ассетов включает это
      brotli_static on;
      gzip_static on;
      add_header Cache-Control public,immutable;
      # ВКЛЮЧЕНИЕ местоположения ассетов (используется для расширяемости)
      # TODO, я не думаю, что этот break нужен, он просто выходит из rewrite
      break;
    }

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

    # кэширование эмодзи
    location ~ /images/emoji/ {
      expires 1y;
      add_header Cache-Control public,immutable;
      add_header Access-Control-Allow-Origin *;
    }

    location ~ ^/uploads/ {

      # ПРИМЕЧАНИЕ: действительно раздражает, что мы не можем просто определить заголовки
      # на верхнем уровне и наследовать их.
      #
      # proxy_set_header НЕ наследуется по дизайну, мы должны повторять его,
      # иначе заголовки не будут установлены правильно
      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;

      ## необязательные правила защиты от хотлинкинга при загрузке
      #valid_referers none blocked mysite.com *.mysite.com;
      #if ($invalid_referer) { return 403; }

      # пользовательские CSS
      location ~ /stylesheet-cache/ {
          add_header Access-Control-Allow-Origin *;
          try_files $uri =404;
      }
      # это позволяет нам обходить Rails
      location ~* \.(gif|png|jpg|jpeg|bmp|tif|tiff|ico|webp|avif)$ {
          add_header Access-Control-Allow-Origin *;
          try_files $uri =404;
      }
      # SVG требует дополнительного заголовка
      location ~* \.(svg)$ {
      }
      # миниатюры и оптимизированные изображения
      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;
    }

    # Этот большой блок необходим для выборочного включения
    # ускорения для резервных копий, аватаров, спрайтов и т.д.
    # см. примечание о повторении выше
    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;

      # если в ответе есть Set-Cookie, ничего не кэшируется
      # это двойной негатив, так как мы не передаем last modified
      proxy_ignore_headers "Set-Cookie";
      proxy_hide_header "Set-Cookie";
      proxy_hide_header "X-Discourse-Username";
      proxy_hide_header "X-Runtime";

      # обратите внимание, x-accel-redirect нельзя использовать с 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;
    }

    # нам нужно отключить буферизацию для 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;
    }

    # это означает, что сначала проверяется каждый файл в public
    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;
  }

}

Теперь ваш Discourse доступен по адресу example.com:443.

Обслуживание

Чтобы получить доступ к консоли Rails, просто выполните rails c как пользователь discourse в /var/www/discourse. Команда discourse, которую можно найти в официальной документации, по сути является bundle exec script/discourse.

Для обновления Discourse обратитесь к #upgrade-cmd, затем перезапустите puma, используя либо puma restart, либо puma phased-restart. Разницу между ними можно узнать по ссылке puma/docs/restart.md at main · puma/puma · GitHub.

8 лайков

Возможно, стоит перенести это в #community-wiki:sysadmins?

Привет. Спасибо за то, что поделились.

Я пишу скрипт для установки этого в контейнере LXC на Debian 12. Он почти готов и работает нормально. Опубликую, когда будет готово.

Мне удалось отобразить первую страницу регистрации администратора. Однако письмо с подтверждением отправляется на адрес discourse@myhostname, а в качестве smtp_server указано myhostname, что абсурдно. Переменные в .bashrc (или .zshrc), а также в discourse.conf не учитываются при отправке email. Адрес электронной почты для разработчика указан верно, но все остальные параметры неверны, и я не могу их изменить. Не могли бы вы подсказать, как это исправить?

1 лайк

Согласно коду

SMTP должен быть настроен в config/discourse.conf.
У меня в этом файле содержатся следующие строки:

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

Вы проверяли логи? Для этой кастомной установки логи находятся в файлах production.log, production.log и puma.stdout.log в директории log.

Большое спасибо за ваш ответ.

У меня уже есть эти настройки в config/discourse.conf. Я сделал всё точно так, как вы написали, только с zsh.
В моих логах нет упоминаний о SMTP, и единственная запись из production.log — это:

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)

В логах моего почтового сервера нет записей от сервера Discourse (хотя для всех моих других серверов (все контейнеры LXC) всё работает). Я могу успешно отправить письмо через терминальную команду mail.

При запуске с помощью 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: (:check_mark:) hot (:multiply:) phased (:multiply:) 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

мой файл 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 у меня такой же, как ваш .zhrc.
Изменение записей developer_emails или db_password работает (на странице регистрации администратора сайта отображается правильный email), но другие параметры SMTP игнорируются.

Ваш файл config/puma.rb содержит ошибки (в районе строки с портом 3000). Не могли бы вы предоставить его снова?

Я зарегистрировал администратора через rails c, и после этого на стартовой странице отображается страница «Ой…». Ни одна страница не отображается корректно.

Пожалуйста, помогите.

@lion, попробуйте это:

# 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"

# Загрузить логгер Logstash, если он включен
if enable_logstash_logger
  require_relative "../lib/discourse_logstash_logger"
  FileUtils.touch(puma_stderr_path) if !File.exist?(puma_stderr_path)
  # Примечание: Возможно, потребуется адаптировать инициализацию логгера для 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

# Количество воркеров (процессов)
workers ENV.fetch("PUMA_WORKERS", 8).to_i

# Установить директорию
directory discourse_path

# Привязаться к указанному адресу и порту
bind ENV.fetch("PUMA_BIND", "tcp://#{ENV['PUMA_BIND_ALL'] ? '' : '127.0.0.1:'}3000")

# Расположение PID-файла
FileUtils.mkdir_p("#{discourse_path}/tmp/pids")
pidfile ENV.fetch("PUMA_PID_PATH", "#{discourse_path}/tmp/pids/puma.pid")

# Файл состояния — используется утилитой pumactl
state_path "#{discourse_path}/tmp/pids/puma.state"

# Конфигурация, специфичная для окружения
if ENV["RAILS_ENV"] == "production"
  # Тайм-аут для продакшена
  worker_timeout 30
else
  # Тайм-аут для разработки
  worker_timeout ENV.fetch("PUMA_TIMEOUT", 60).to_i
end

# Предзагрузка приложения
preload_app!

# Обработка запуска и остановки воркеров
before_fork do
  Discourse.preload_rails!
  Discourse.before_fork

  # Проверка супервизора
  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

  # Воркеры 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
        # Задержка повторного открытия логов Sidekiq
        sleep 1
        Demon::Sidekiq.kill("USR2")
      end
    end
  end

  # Демон синхронизации почты
  if ENV["DISCOURSE_ENABLE_EMAIL_SYNC_DEMON"] == "true"
    puts "starting up EmailSync demon"
    Demon::EmailSync.start(1)
  end

  # Демоны плагинов
  DiscoursePluginRegistry.demon_processes.each do |demon_class|
    puts "starting #{demon_class.prefix} demon"
    demon_class.start(1)
  end

  # Поток мониторинга демонов
  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

  # Закрыть соединение с Redis
  Discourse.redis.close
end

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

# Обработка тайм-аута воркера
worker_timeout 30

# Низкоуровневые опции воркера
threads 8, 32

Спасибо. Я забыл упомянуть, что за ним стоит HAProxy для нескольких серверов.

  1. В консоли браузера я получаю сообщение о «смешанном контенте». Что я могу изменить в файле nginx?
  2. При запуске команды puma я вижу эту запись в логе о Magick, хотя она установлена с самого начала.

==> /var/www/discourse/log/puma.stderr.log <==
=== запуск puma: 2025-09-19 01:40:45 +0200 ===
неизвестный OID 16720: не удалось распознать тип ‘embeddings’. Он будет обработан как строка.
#<Thread:0x00007fc59a2f5a78 /var/www/discourse/lib/discourse.rb:1190 run> завершен с исключением (report_on_exception = true):
/var/www/discourse/lib/letter_avatar.rb:112:in ``‘: Нет такого файла или каталога - 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!’
Я читал, что это может быть причиной того, что страницы не отображаются.

Вы принудительно включаете SSL в настройках Discourse?

Можете ли вы выполнить команду magick --version от имени пользователя discourse? У меня вывод выглядит так:
Version: ImageMagick 7.1.1-45 Q16-HDRI x86_64 3cbce5696:20250308 https://imagemagick.org
Copyright: (C) 1999 ImageMagick Studio LLC
License: ImageMagick | License
Features: Cipher DPC HDRI Modules OpenMP(4.5)
Delegates (built-in): 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
Compiler: gcc (13.3)

Да, именно так я и думал.

При «ручной» установке я обнаружил множество неотвеченных зависимостей. Я пишу Ansible-скрипт для развёртывания Discourse в нескольких окружениях.

Мои системы работают на AlmaLinux/RedHat, но я поделюсь найденным, когда почти завершу работу.

1 лайк

В haproxy.cfg порт 443 использует сертификат (crt), то есть зашифрован, и перенаправляется в контейнер через порт 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 #добавить “check ssl verify none”, если в nginx discourse включён SSL

  1. В файле nginx/…/discourse.conf я скопировал версию из этого поста и изменил порт, имя сервера и пути к файлам сертификатов.
    => Пробл. 1: получил смешанный контент и логи с той же ошибкой, связанной с magick.
    => Пробл. 2: на странице “/confirm-email” браузер выдаёт ошибку “сайт не найден”.

  2. Затем я отредактировал файл, убрав SSL из порта и закомментировав все строки с “$thescheme”, чтобы шифрование осуществлялось только через haproxy.
    => Ошибка 502 на первой странице, те же логи с magick.

  3. После этого я установил magick из исходного кода (версия 7.1.2.3), добавил папку с бинарниками в переменную PATH в .bashrc для пользователя root и для пользователя discourse. Установка через apt install не сработала (не дала никакого эффекта).
    => Ошибка 502 исчезла, но остаются смешанный контент и ошибка “сайт не найден” в браузере.
    => В логах всё ещё сообщается, что magick не найден, но теперь с другим сообщением.

==> /var/www/discourse/log/puma.stderr.log <==
=== запуск puma: 2025-09-19 12:06:05 +0200 ===
Неизвестный OID 16720: не удалось распознать тип ‘embeddings’. Он будет обрабатываться как String.
#<Thread:0x00007f611a21c0f0 /var/www/discourse/lib/discourse.rb:1190 run> завершён с исключением (report_on_exception установлен в true):
/var/www/discourse/lib/letter_avatar.rb:112:in ``‘: Нет такого файла или директории - 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] ПРЕДУПРЕЖДЕНИЕ: хук before_fork завершён с исключением (Errno::ENOENT) Нет такого файла или директории - magick

Вывод:
Как сделать так, чтобы puma находил magick?
Как отключить SSL в nginx, поскольку шифрование уже осуществляется через haproxy?

Мне удалось заставить это работать, как описано в моем первом сообщении, но здесь без SSL на nginx, скопировав nginx.config.sample и изменив только имя хоста и порт.
Таким образом, как с SSL, так и без него на nginx, я вижу первую страницу регистрации администратора до страницы повторной отправки письма, но в обоих случаях:

  1. отображается предупреждение о «смешанном контенте» (например, для файлов изображений),
  2. на других страницах, кроме регистрации администратора, отображается сообщение «Ой…»,
  3. письма с подтверждением не отправляются,
  4. утилита Magick по-прежнему не найдена.

Где-то в одном из ваших постов вы упоминаете контейнеры.

Используете ли вы контейнеры для каких-либо из задействованных компонентов?

Сегодня я хочу завершить развёртывание в среде preprod, и, возможно, у меня появится больше информации, которой можно поделиться.

То же самое :unamused_face:

  • Не отправляется письмо с подтверждением (использую mailtrap, уверен, что всё работает).
  • Создал пользователя-администратора через rake.
  • Ошибка на / после этого…
  • Также видел проблему с Magick, хотя Magick установлен (уверен, что не хватает какого-то rubygem’а…)

Разворачиваю через Docker и сравниваю. Ненавижу Docker :expressionless_face:, но придется потратить кучу драгоценного времени…

Я почти не знаю Docker (если вообще знаю его!) и мне не пришлось его изучать, чтобы запустить форум Discourse в продакшене. Просто следуя официальным инструкциям, вы получите всё необходимое.

Да, я тебе верю. Но мне нужно знать, что я разворачиваю и как это работает.

Это лучший способ найти и решить проблемы :squinting_face_with_tongue:

Потому что проблемы у тебя будут, всегда. Может, не сейчас, но уверен, что в будущем ты столкнёшься с ними.

В любом случае, раз поддержка для альтернативных методов установки не предусмотрена, я буду следовать официальным инструкциям и постараюсь помогать другим :winking_face_with_tongue:

Я также потратил много времени на эти проблемы.

Что касается Docker, посмотрите эту тему о Docker (ошибка или бэкдор?..), которая на данный момент исправлена: https://youtu.be/dTqxNc1MVLE

Это одна из причин, по которой я не стал бы использовать Docker.

Я использую контейнеры LXD (LXC) и чувствую себя с ними комфортно. Сначала я установлю Docker в контейнер LXC, а позже экспортирую базу данных, когда станет возможна установка без Docker в контейнере LXC.

Дело в том, что я не принуждаю Discourse использовать SSL, так как хочу делегировать это HAProxy. Пропуск трафика (passthrough) в HAProxy не работает с HTTP-протоколом для перенаправления GET-запросов, а так как я обслуживаю несколько веб-сайтов, мне необходим HTTP-протокол в HAProxy, что требует обработки SSL на стороне HAProxy. Я хотел бы избежать двойного SSL-шлюза.

Таким образом, HAProxy (на одном LXC-контейнере) слушает порт 443 и перенаправляет трафик на порт 8080 (без SSL) в мой контейнер Discourse (LXC).

Любопытно, что файл nginx-config-sample из папки Discourse настроен без SSL и на порту 80, поэтому он должен работать корректно, но я сталкиваюсь с вышеупомянутыми проблемами.

Вам нужно сделать это, чтобы сообщить Discourse отправлять только ссылки HTTPS. Это не меняет перенаправления, но устраняет необходимость в них.

Вам нужно включить настройку принудительного использования HTTPS.

HTTPS: Да, но подскажите, пожалуйста, как его включить?

ОТЛИЧНЫЕ НОВОСТИ! Я РЕШИЛ ПРОБЛЕМЫ!!! Источником была программа ImageMagick. После правильной установки ImageMagick из исходного кода версии 7 (не устанавливайте ImageMagick через apt, там версия 6), мой сайт Discourse корректно отображает все страницы! Электронная почта, регистрация администратора и т. д. работают! После решения проблемы «смешанного контента» я опубликую свой скрипт для установки в контейнере LXD-LXC (Debian 12) за HAProxy на другом контейнере LXD-LXC.

1 лайк