Docker 없이 Discourse 배포하기

공식 설치 가이드에 따라 Discourse를 배포하는 것이 더 편리하고 안전하지만, 컨테이너를 더 깊이 파고들어 Docker 없이 Linux에서 어떻게 배포할 수 있는지 살펴보고 싶습니다. 이 단계들을 공유하는 것은 단지 참고용입니다. 해당 내용을 적용하고 사용하는 것은 전적으로 본인의 책임 하에 이루어져야 합니다.

컨테이너에서 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를 사용합니다.

PG의 공식 설치 가이드를 따라 PostgreSQL Apt Repository에서 postgres를 설치합니다. 작성 시점의 공식 설치에서는 15버전을 사용하지만, 저는 18버전을 설치했으며 잘 작동합니다.

redis(작성 시점의 공식 설치에서는 7.0을 사용하지만 8.2), 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'

# Add RVM to PATH for scripting. Make sure this is the last PATH variable change.
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

.zshrc가 적용되도록 discourse 사용자로 로그아웃했다가 다시 로그인합니다.

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 설치

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 설치, db 마이그레이션, 자산 프리컴파일 등을 수행합니다. 이것이 또한 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는 Unicorn 대신 Puma 웹 서버 사용을 권장합니다. config/unicorn.conf.rb를 참고하여 작성한 제 config/puma.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"

# Load logstash logger if enabled
if enable_logstash_logger
  require_relative "../lib/discourse_logstash_logger"
  FileUtils.touch(puma_stderr_path) if !File.exist?(puma_stderr_path)
  # Note: You may need to adapt the logger initialization for 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

# Number of workers (processes)
workers ENV.fetch("PUMA_WORKERS", 6).to_i

# Set the directory
directory discourse_path

# Bind to the specified address and port
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'
#customization:plugin :acme
#acme_server_name 'example.com'
#acme_tos_agreed true
#bind 'acme://0.0.0.0:443'

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

# State file - used by pumactl
state_path "#{discourse_path}/tmp/pids/puma.state"

# Environment-specific configuration
if ENV["RAILS_ENV"] == "production"
  # Production timeout
  worker_timeout 30
else
  # Development timeout
  worker_timeout ENV.fetch("PUMA_TIMEOUT", 60).to_i
end

# Preload application
preload_app!

# Handle worker boot and shutdown
before_fork do
  Discourse.preload_rails!
  Discourse.before_fork

  # Supervisor check
  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 workers
  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
        # Delay Sidekiq log reopening
        sleep 1
        Demon::Sidekiq.kill("USR2")
      end
    end
  end

  # Email sync demon
  if ENV["DISCOURSE_ENABLE_EMAIL_SYNC_DEMON"] == "true"
    puts "starting up EmailSync demon"
    Demon::EmailSync.start(1)
  end

  # Plugin demons
  DiscoursePluginRegistry.demon_processes.each do |demon_class|
    puts "starting #{demon_class.prefix} demon"
    demon_class.start(1)
  end

  # Demon monitoring thread
  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

  # Close Redis connection
  Discourse.redis.close
end

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

# Worker timeout handling
worker_timeout 30

# Low-level worker options
threads 8, 32

Discourse를 실행하려면 puma -C config/puma.rb를 실행합니다.

systemd를 사용하면 부팅 시 실행하고 실패 시 재시작할 수 있습니다. 서비스 파일은 다음과 같습니다:

/etc/systemd/system/discourse.service
[Unit]
Description=Discourse with Puma Server
After=network.target postgresql.service
Requires=postgresql.service

[Service]
Type=simple
User=discourse
Group=discourse
WorkingDirectory=/var/www/discourse
# requires running `rvm 3.4.6 --default` before this service is run
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 configuration
Restart=always
RestartSec=5s

# Basic security measures
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=read-only

[Install]
WantedBy=multi-user.target

이제 puma 서버가 127.0.0.1:3000에서 리스닝합니다. Docker에서 가져온 nginx 설정 파일을 수정합니다:

/etc/nginx/sites-enabled/discourse.conf
# Additional MIME types that you'd like nginx to handle go in here
types {
    text/csv csv;
    #application/wasm wasm;
}

upstream discourse { server 127.0.0.1:3000; }

# inactive means we keep stuff around for 1440m minutes regardless of last access (1 week)
# levels means it is a 2 deep hierarchy cause we can have lots of files
# max_size limits the size of the cache
proxy_cache_path /var/nginx/cache inactive=1440m levels=1:2 keys_zone=one:10m max_size=600m;

# Increased from the default value to acommodate large cookies during oAuth2 flows
# like in https://meta.discourse.org/t/x/74060 and large CSP and Link (preload) headers
proxy_buffer_size 32k;
proxy_buffers 4 32k;

# Increased from the default value to allow for a large volume of cookies in request headers
# Discourse itself tries to minimise cookie size, but we cannot control other cookies set by other tools on the same domain.
large_client_header_buffers 4 32k;

# attempt to preserve the proto, must be in http context
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"';

# Allow bypass cache from 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;

  # Uncomment and configure this section for HTTPS support
  # NOTE: Put your ssl cert in your main nginx config directory (/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;

  # maximum file upload size (keep up to date when changing the corresponding site setting)
  client_max_body_size 128m ;


  # path to discourse's public directory
  set $public /var/www/discourse/public;

  # without weak etags we get zero benefit from etags on dynamically compressed content
  # further more etags are based on the file in nginx not sha of data
  # use dates, it solves the problem fine even cross server
  etag off;

  # prevent direct download of backups
  location ^~ /backups/ {
    internal;
  }

  # bypass rails stack with a cheap 204 for favicon.ico requests
  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;
    }

    # some minimal caching here so we don't keep asking
    # longer term we should increase probably to 1y
    location ~ ^/javascripts/ {
      expires 1d;
      add_header Cache-Control public,immutable;
      add_header Access-Control-Allow-Origin *;
    }

    location ~ ^/assets/(?<asset_path>.+)$ {
      expires 1y;
      # asset pipeline enables this
      brotli_static on;
      gzip_static on;
      add_header Cache-Control public,immutable;
      # HOOK in asset location (used for extensibility)
      # TODO I don't think this break is needed, it just breaks out of rewrite
      break;
    }

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

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

    location ~ ^/uploads/ {

      # NOTE: it is really annoying that we can't just define headers
      # at the top level and inherit.
      #
      # proxy_set_header DOES NOT inherit, by design, we must repeat it,
      # otherwise headers are not set correctly
      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;

      ## optional upload anti-hotlinking rules
      #valid_referers none blocked mysite.com *.mysite.com;
      #if ($invalid_referer) { return 403; }

      # custom CSS
      location ~ /stylesheet-cache/ {
          add_header Access-Control-Allow-Origin *;
          try_files $uri =404;
      }
      # this allows us to bypass rails
      location ~* \.(gif|png|jpg|jpeg|bmp|tif|tiff|ico|webp|avif)$ {
          add_header Access-Control-Allow-Origin *;
          try_files $uri =404;
      }
      # SVG needs an extra header attached
      location ~* \.(svg)$ {
      }
      # thumbnails & optimized images
      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;
    }

    # This big block is needed so we can selectively enable
    # acceleration for backups, avatars, sprites and so on.
    # see note about repetition above
    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;

      # if Set-Cookie is in the response nothing gets cached
      # this is double bad cause we are not passing last modified in
      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 can not be used with 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;
    }

    # we need buffering off for 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;
    }

    # this means every file in public is tried first
    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;
  }

}

이제 example.com:443에서 Discourse에 액세스할 수 있습니다.

유지보수

rails 콘솔에 액세스하려면 /var/www/discourse에서 discourse 사용자로 rails c를 실행하면 됩니다. 공식 문서에서 찾을 수 있는 discourse 명령은 기본적으로 bundle exec script/discourse입니다.

Discourse를 업그레이드하려면 #upgrade-cmd를 참고한 후 puma restart 또는 puma phased-restart 중 하나를 사용하여 puma를 재시작합니다. 두 명령의 차이에 대해서는 puma/docs/restart.md at main · puma/puma · GitHub 를 참고하십시오.

8개의 좋아요

Should this be moved to #community-wiki:sysadmins, perhaps?

안녕하세요. 공유해 주셔서 감사합니다.

저는 이 소프트웨어를 Debian 12 LXC 컨테이너에 설치하기 위한 스크립트를 작성하고 있습니다. 거의 마무리 단계에 있으며 잘 작동하고 있습니다. 준비가 되는 대로 공개할 예정입니다.

관리자 등록 첫 페이지를 표시하는 데는 성공했습니다. 하지만 확인 이메일이 discourse@myhostname으로 전송되고, smtp_server로는 myhostname으로 설정되어 있어 말이 되지 않습니다. .bashrc(또는 .zshrc)의 변수도, discourse.conf의 변수도 이메일 전송 시 반영되지 않습니다. 개발자 이메일 주소는 정확하지만, 다른 모든 매개변수는 잘못되어 있고 변경할 수도 없었습니다. 이 문제를 해결하는 방법에 대해 혹시 아이디어가 있으신가요?

1개의 좋아요

코드를 참고하면

SMTP는 config/discourse.conf에서 설정해야 합니다.
저의 경우 해당 파일에 다음과 같은 줄들이 있습니다:

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

로그를 확인해 보셨나요? 이 커스텀 설치 환경에서는 로그가 log 디렉터리 안의 production.log, production.log, puma.stdout.log에 있습니다.

답변해 주셔서 정말 감사합니다.

이미 config/discourse.conf 파일에 해당 설정들을 적용해 두었습니다. 작성해 주신 내용 그대로 실행하되, zsh를 사용했습니다.
로그에는 smtp에 대한 언급이 없고, production.log의 유일한 from 항목은 다음과 같습니다.

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와 동일하게 설정되어 있습니다.
developper_emails 또는 db_password 항목을 수정하면 정상적으로 작동합니다(웹사이트 관리자 등록 페이지에 올바른 이메일이 표시됩니다), 하지만 다른 smtp 파라미터들은 무시됩니다.

config/puma.rb 파일에 몇 가지 오류가 있습니다 (le port 3000이 포함된 줄 근처). 파일을 다시 제공해 주시겠어요?

rails c를 통해 관리자 계정을 등록한 후 시작 페이지에서 “Oops…” 페이지가 표시됩니다. 어떤 페이지도 올바르게 렌더링되지 않습니다.

도움을 부탁드립니다.

@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. 브라우저 콘솔에서 “mix content” 오류가 발생합니다. nginx 파일에서 무엇을 수정해야 하나요?
  2. puma 명령을 실행할 때 magick 관련 로그가 출력됩니다. 이미 설치되어 있음에도 불구하고 발생하는 문제입니다.

==> /var/www/discourse/log/puma.stderr.log <==
=== puma startup: 2025-09-19 01:40:45 +0200 ===
unknown OID 16720: failed to recognize type of ‘embeddings’. It will be treated as String.
#<Thread:0x00007fc59a2f5a78 /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!’
이것이 페이지가 표시되지 않는 원인이 될 수 있다는 내용을 읽었습니다.

Discourse 설정에서 SSL을 강제하고 계신가요?

discourse 사용자 계정으로 magick --version 명령을 실행해 볼 수 있을까요? 제 경우 출력은 다음과 같습니다:
Version: ImageMagick 7.1.1-45 Q16-HDRI x86_64 3cbce5696:20250308 https://imagemagick.org
Copyright: (C) 1999 ImageMagick Studio LLC
License: Redirecting...
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)

네, 정확히 제 생각과 같습니다.

수동으로 설치하는 과정에서 충족되지 않은 의존성이 많이 발견되었습니다. 여러 환경에 Discourse를 배포할 수 있도록 Ansible 스크립트를 작성 중입니다.

저희 시스템은 AlmaLinux/RHEL을 사용하지만, 거의 완료 단계에 도달하면 발견한 내용을 공유하겠습니다.

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 # nginx discourse에 ssl이 켜져 있으면 "check ssl verify none"을 추가

  1. nginx/…/discourse.conf에서 이 게시물의 버전을 복사하고 포트, 서버 이름, 인증서 파일 위치를 수정했습니다.
    => PB1: 혼합 콘텐츠(mixed contents) 오류가 발생했으며, magick 관련 동일한 문제가 로그에 나타남
    => PB2: “/confirm-email” 페이지에서 브라우저가 “site not found” 오류를 표시함

  2. 그런 다음 파일을 수정하여 포트의 ssl을 제거하고 "$thescheme

첫 번째 게시물에서 설명한 대로 작동하도록 만들었습니다. 다만 nginx에서 SSL을 사용하지 않고, nginx.config.sample 파일을 복사한 뒤 호스트명과 포트만 변경하여 설정했습니다.
즉, nginx에서 SSL을 사용하든 사용하지 않든, 관리자 등록 페이지에서 이메일 재전송 페이지까지 첫 번째 페이지는 표시되지만, 두 경우 모두 다음 문제가 발생합니다:

  1. 이미지 파일 등에서 “혼합 콘텐츠(mixed content)” 오류가 표시됩니다.
  2. 관리자 등록 페이지를 제외한 다른 페이지에서는 “Ooops…” 메시지가 표시됩니다.
  3. 확인 이메일이 전송되지 않습니다.
  4. magick이 여전히 발견되지 않습니다.

글 중 어딘가에 컨테이너에 대한 언급이 있습니다.

관련된 부분 중 컨테이너를 사용하고 계신가요?

오늘 프로덕션 이전(preprod) 환경에서 배포를 마무리할 예정이며, 추가 정보를 공유할 수 있을지도 모릅니다.

너랑 똑같아 :unamused_face:

  • 확인 이메일이 안 와 (mailtrap을 쓰고 있는데, 제대로 작동하는 건 확실해).
  • rake로 관리자 사용자를 생성했어.
  • 그 뒤 /에서 에러가 발생했지…
  • magick 관련 문제도 봤고, magick은 설치되어 있어 (rubygem이 하나 빠진 것 같아…)

docker로 배포한 뒤 비교해볼 거야. docker는 싫어 :expressionless_face: 하지만 귀한 시간을 많이 낭비하게 될 테니까…

저는 도커를 거의 알지 못합니다(전혀 몰랐을 수도 있죠!). 그리고 프로덕션 환경에서 디스코urses 포럼을 실행하기 위해 도커를 알아야 할 필요도 없었습니다. 지원되는 지침을 따르기만 하면 필요한 것을 얻을 수 있습니다.

응, 믿어. 하지만 내가 뭘 배포하는 건지, 어떻게 작동하는지 알아야 해.

문제를 찾고 해결하는 가장 좋은 방법이지 :squinting_face_with_tongue:

문제는 생길 거야, 반드시 지금이 아니더라도, 미래의 너는 분명 누군가를 만나게 될 거야.

어쨌든, 대안 설치 방법을 쓰면 지원을 받을 수 없으니 공식 지침을 따르고 다른 사람들을 돕는 데 힘써볼게 :winking_face_with_tongue:

저도 이 문제들에 대해 많은 시간을 보냈습니다.
Docker에 관해서는 Docker의 이 이슈(실수인지 백도어인지?..)를 참고해 주세요. 현재는 수정된 상태입니다: https://youtu.be/dTqxNc1MVLE
이것이 제가 Docker를 사용하지 않으려는 이유 중 하나입니다.
저는 lxd (lxc) 컨테이너를 사용하고 있으며, 그것에 대해 만족하고 있습니다. 일단 lxc 컨테이너에 Docker를 설치한 후, Docker 없이 lxc에서 설치가 가능해지면 나중에 데이터베이스를 내보내겠습니다.

저는 haproxy가 SSL을 처리하도록 하려고 Discourse에 SSL을 강제하지 않았습니다. haproxy의 패스스루(pass-through)는 GET 요청을 리다이렉트하기 위해 HTTP 프로토콜과 호환되지 않기 때문이며, 여러 웹사이트를 처리해야 하므로 haproxy에서 HTTP 프로토콜이 필요합니다. 따라서 haproxy 측에서 SSL 처리가 필요합니다. 이중 SSL 게이트웨이를 피하고 싶습니다.
즉, haproxy(하나의 LXC에서)는 443 포트를 청취하며, SSL 없이 8080 포트로 내 Discourse 컨테이너(LXC)로 리다이렉트합니다.
흥미로운 점은 discourse 폴더에 제공되는 nginx-config-sample이 SSL 없이 80 포트로 구성되어 있어 잘 작동할 것 같지만, 위에서 언급한 문제가 발생한다는 것입니다.

Discourse가 HTTPS 링크만 보내도록 설정하려면 그렇게 해야 합니다. 리다이렉트 자체를 변경하는 것이 아니라, 리다이렉트가 필요한 이유를 바꾸는 것입니다.

force https 설정을 켜야 합니다.

HTTPS: 네, 하지만 어떻게 활성화하는지 알려줄 수 있나요?

좋은 소식입니다! 문제를 해결했습니다!!! 원인은 magick 프로그램이었습니다. 버전 7의 magick을 소스에서 올바르게 설치한 후(apt를 사용하여 imagemagick을 설치하지 마세요. 이는 버전 6입니다), 제 discourse 웹사이트가 모든 페이지를 올바르게 표시합니다! 이메일, 관리자 등록 등이 작동합니다! “mixed content” 문제를 해결한 후, haproxy 뒤의 다른 lxd-lxc 컨테이너에 있는 lxd-lxc 컨테이너(Debian 12)에 대한 설치 스크립트를 게시할 것입니다.

1개의 좋아요