diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..59a67ac --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +node = "latest" diff --git a/templates/caddy.template.yml b/templates/caddy.template.yml new file mode 100644 index 0000000..bdf6383 --- /dev/null +++ b/templates/caddy.template.yml @@ -0,0 +1,660 @@ +env: + # You can have redis on a different box + RAILS_ENV: 'production' + UNICORN_WORKERS: 3 + UNICORN_SIDEKIQS: 1 + # stop heap doubling in size so aggressively, this conserves memory + RUBY_GC_HEAP_GROWTH_MAX_SLOTS: 40000 + RUBY_GC_HEAP_INIT_SLOTS: 400000 + RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR: 1.5 + DISCOURSE_FORCE_HTTPS: true + + DISCOURSE_DB_SOCKET: /var/run/postgresql + DISCOURSE_DB_HOST: + DISCOURSE_DB_PORT: + +params: + version: tests-passed + home: /var/www/discourse + upload_size: 10m + reqs_per_second: 12 + reqs_per_minute: 200 + conn_per_ip: 20 + +run: + - exec: thpoff echo "thpoff is installed!" + - exec: + tag: precompile + cmd: + - /usr/local/bin/ruby -e 'if ENV["DISCOURSE_SMTP_ADDRESS"] == "smtp.example.com"; puts "Aborting! Mail is not configured!"; exit 1; end' + - /usr/local/bin/ruby -e 'if ENV["DISCOURSE_HOSTNAME"] == "discourse.example.com"; puts "Aborting! Domain is not configured!"; exit 1; end' + - /usr/local/bin/ruby -e 'if (ENV["DISCOURSE_CDN_URL"] || "")[0..1] == "//"; puts "Aborting! CDN must have a protocol specified. Once fixed you should rebake your posts now to correct all posts."; exit 1; end' + - /usr/local/bin/ruby -e 'if ENV["LETSENCRYPT_ACCOUNT_EMAIL"] == nil || ENV["LETSENCRYPT_ACCOUNT_EMAIL"] == ""; puts "Aborting! LETSENCRYPT_ACCOUNT_EMAIL ENV variable is required and has not been set."; exit 1; end' + - /bin/bash -c "if [[ ! \"$LETSENCRYPT_ACCOUNT_EMAIL\" =~ ([^@]+)@([^\.]+) ]]; then echo \"LETSENCRYPT_ACCOUNT_EMAIL is not a valid email address\"; exit 1; fi" + # TODO: move to base image (anacron can not be fired up using rc.d) + - exec: rm -f /etc/cron.d/anacron + - file: + path: /etc/cron.d/anacron + contents: | + SHELL=/bin/sh + PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin + + 30 7 * * * root /usr/sbin/anacron -s >/dev/null + - file: + path: /etc/runit/1.d/copy-env + chmod: "+x" + contents: | + #!/bin/bash + env > ~/boot_env + conf=/var/www/discourse/config/discourse.conf + + # find DISCOURSE_ env vars, strip the leader, lowercase the key + /usr/local/bin/ruby -e 'ENV.each{|k,v| puts "#{$1.downcase} = '\''#{v}'\''" if k =~ /^DISCOURSE_(.*)/}' > $conf + + - file: + path: /etc/service/unicorn/run + chmod: "+x" + contents: | + #!/bin/bash + exec 2>&1 + # redis + # postgres + cd $home + chown -R discourse:www-data /shared/log/rails + # before precompile + if [[ -z "$PRECOMPILE_ON_BOOT" ]]; then + PRECOMPILE_ON_BOOT=1 + fi + if [ -f /usr/local/bin/create_db ] && [ "$CREATE_DB_ON_BOOT" = "1" ]; then /usr/local/bin/create_db; fi; + if [ "$MIGRATE_ON_BOOT" = "1" ]; then su discourse -c 'bundle exec rake db:migrate'; fi + if [ "$PRECOMPILE_ON_BOOT" = "1" ]; then SKIP_EMBER_CLI_COMPILE=1 su discourse -c 'bundle exec rake assets:precompile'; fi + 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 + + - file: + path: /etc/service/caddy/run + chmod: "+x" + contents: | + #!/bin/bash + exec 2>&1 + echo "Starting Caddy web server..." + cd /var/www/discourse + exec /usr/bin/caddy run --config /etc/caddy/Caddyfile --adapter caddyfile + + - file: + path: /etc/runit/3.d/01-caddy + chmod: "+x" + contents: | + #!/bin/bash + sv stop caddy + + - file: + path: /etc/runit/3.d/02-unicorn + chmod: "+x" + contents: | + #!/bin/bash + sv stop unicorn + + - exec: + cd: $home + hook: code + cmd: + - sudo -H -E -u discourse git clean -f + # TODO Remove the special handling of shallow clones when everyone uses images without that clone type + - |- + sudo -H -E -u discourse bash -c ' + set -o errexit + if [ $(git rev-parse --is-shallow-repository) == "true" ]; then + git remote set-branches --add origin main + git remote set-branches origin $version + git fetch --depth 1 origin $version + else + git fetch --tags --prune-tags --prune --force origin + fi + ' + - |- + sudo -H -E -u discourse bash -c ' + set -o errexit + if [[ $(git symbolic-ref --short HEAD) == $version ]] ; then + git pull + else + git -c advice.detachedHead=false checkout $version + fi + ' + - sudo -H -E -u discourse git config user.discourse-version $version + - mkdir -p tmp + - chown discourse:www-data tmp + - mkdir -p tmp/pids + - mkdir -p tmp/sockets + - touch tmp/.gitkeep + - mkdir -p /shared/log/rails + - bash -c "touch -a /shared/log/rails/{production,production_errors,unicorn.stdout,unicorn.stderr,sidekiq}.log" + - bash -c "ln -s /shared/log/rails/{production,production_errors,unicorn.stdout,unicorn.stderr,sidekiq}.log $home/log" + - bash -c "mkdir -p /shared/{uploads,backups}" + - bash -c "ln -s /shared/{uploads,backups} $home/public" + - bash -c "mkdir -p /shared/tmp/{backups,restores}" + - bash -c "ln -s /shared/tmp/{backups,restores} $home/tmp" + - chown -R discourse:www-data /shared/log/rails /shared/uploads /shared/backups /shared/tmp + # scrub broken symlinks from plugins that have been removed + - "[ ! -d public/plugins ] || find public/plugins/ -maxdepth 1 -xtype l -delete" + + - exec: + cmd: + - apt-get update && apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg zstd + - curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg + - curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list + - apt-get update && apt-get install -y caddy curl + - mkdir -p /etc/caddy + - mkdir -p /var/caddy/data + - mkdir -p /var/log/caddy + - chown -R caddy:caddy /var/log/caddy + # Use the standard Caddy with its built-in features + - setcap CAP_NET_BIND_SERVICE=+eip /usr/bin/caddy + - echo "Ensuring IPv6 support, zstd compression, and basic rate limiting are enabled" + + - file: + path: /etc/caddy/Caddyfile + contents: | + { + admin off + email {$LETSENCRYPT_ACCOUNT_EMAIL} + + # Set storage location for certificates + storage file_system { + root /var/caddy/data + } + + # Logging configuration + log { + format json + output file /var/log/caddy/access.log { + roll_size 50MiB + roll_keep 10 + } + } + + # Simple global options that are properly supported + auto_https on + # HTTP/3 is enabled by default + # IPv6 is supported by default + + # Rate limiting settings + } + + {$DISCOURSE_HOSTNAME} { + root * /var/www/discourse/public + + # Auto HTTPS managed by Caddy + # Caddy will automatically obtain and renew certificates from Let's Encrypt + + # Basic rate limiting for all requests to this host + @ratelimit { + path /* + } + handle @ratelimit { + rate_limit { + zone global + rate {$reqs_per_second}/s + } + } + + # Connection limiting + limit_connections {$conn_per_ip} + + # Security headers + header { + # HSTS header with a long max age + Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" + # Prevent MIME type sniffing + X-Content-Type-Options "nosniff" + # Frame options to prevent clickjacking + X-Frame-Options "SAMEORIGIN" + # XSS protection + X-XSS-Protection "1; mode=block" + # Basic CSP + Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' wss://{host}; upgrade-insecure-requests;" + # Referrer policy + Referrer-Policy "strict-origin-when-cross-origin" + # Permissions policy + Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" + } + + # Auth option if needed + # basicauth { + # user bcrypt-hash + # } + + # Cache static assets + @static { + path /assets/* /plugins/* /images/emoji/* + file { + try_files {path} + } + } + header @static Cache-Control "public, max-age=31536000, immutable" + header @static Access-Control-Allow-Origin "*" + + # Cache fonts + @fonts { + path *.eot *.ttf *.woff *.woff2 *.ico *.otf + path /fonts/* /assets/* /plugins/* /uploads/* + file { + try_files {path} + } + } + header @fonts Cache-Control "public, max-age=31536000, immutable" + header @fonts Access-Control-Allow-Origin "*" + + # Handle uploads + @uploads { + path /uploads/* + } + handle @uploads { + header Cache-Control "public, max-age=31536000, immutable" + + # Custom CSS + @stylesheetCache { + path /uploads/stylesheet-cache/* + } + handle @stylesheetCache { + header Access-Control-Allow-Origin "*" + try_files {path} =404 + } + + # Images bypass Rails + @images { + path *.gif *.png *.jpg *.jpeg *.bmp *.tif *.tiff *.ico *.webp *.avif + } + handle @images { + header Access-Control-Allow-Origin "*" + try_files {path} =404 + } + + # Thumbnails & optimized images + @optimized { + path /uploads/*_optimized/* /uploads/optimized/* + } + handle @optimized { + header Access-Control-Allow-Origin "*" + try_files {path} =404 + } + + # Fall back to Rails for other uploads + reverse_proxy http://127.0.0.1:3000 + } + + # Secure uploads handling + @secureUploads { + path /secure-media-uploads/* /secure-uploads/* + } + handle @secureUploads { + reverse_proxy http://127.0.0.1:3000 + } + + # Short URL handling + handle /uploads/short-url/* { + reverse_proxy http://127.0.0.1:3000 + } + + # Favicon quick bypass + handle /favicon.ico { + respond 204 + } + + # Javascript caching + @javascripts { + path /javascripts/* + } + handle @javascripts { + header Cache-Control "public, max-age=86400, immutable" + header Access-Control-Allow-Origin "*" + try_files {path} =404 + } + + # Message bus without buffering + handle /message-bus/* { + reverse_proxy http://127.0.0.1:3000 { + transport http { + keepalive 30s + versions 1.1 2 + } + flush_interval -1 + } + } + + # Backups protection + handle /backups/* { + respond 403 + } + + # Admin backups + handle /admin/backups/* { + reverse_proxy http://127.0.0.1:3000 { + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Request-Start "t={unix_time_milli}" + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } + } + + # Health check + handle /srv/status { + reverse_proxy http://127.0.0.1:3000 + } + + # Default handling + handle { + try_files {path} @discourse + } + + # Rails app handling + handle @discourse { + reverse_proxy http://127.0.0.1:3000 { + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Request-Start "t={unix_time_milli}" + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } + } + + # Enable compression with zstd preferred over gzip (both built into Caddy) + encode { + # List zstd first to make it the preferred encoding when supported + zstd + gzip 6 + minimum_length 512 + match { + # Compress text-based content types + content_type application/json text/css text/javascript application/javascript application/x-javascript text/xml application/xml application/xml+rss application/wasm font/ttf font/otf + # Don't compress already compressed formats + not content_type image/* video/* audio/* application/pdf application/zip + } + } + } + + - exec: + cmd: + - echo "force_https = 'true'" >> "/var/www/discourse/config/discourse.conf" + - echo "done configuring web with Caddy rate limiting" + hook: web_config + + - exec: + cd: $home + hook: web + cmd: + # install bundler version to match Gemfile.lock + - gem install bundler --conservative -v $(awk '/BUNDLED WITH/ { getline; gsub(/ /,""); print $0 }' Gemfile.lock) + - find $home ! -user discourse -exec chown discourse {} \+ + + - exec: + cd: $home + hook: yarn + cmd: + - |- + if [ -f yarn.lock ]; then + if [ -d node_modules/.pnpm ]; then + echo "This version of Discourse uses yarn, but pnpm node_modules are preset. Cleaning up..." + find ./node_modules ./app/assets/javascripts/*/node_modules -mindepth 1 -maxdepth 1 -exec rm -rf {} + + fi + su discourse -c 'yarn install --frozen-lockfile && yarn cache clean' + else + su discourse -c 'CI=1 pnpm install --frozen-lockfile && pnpm prune' + fi + + - exec: + cd: $home + hook: bundle_exec + cmd: + - su discourse -c 'bundle install --jobs $(($(nproc) - 1)) --retry 3' + - su discourse -c 'bundle clean' + + - exec: + cd: $home + cmd: + - su discourse -c 'LOAD_PLUGINS=0 bundle exec rake plugin:pull_compatible_all' + hook: plugin_compatibility + raise_on_fail: false + + - exec: + cd: $home + tag: migrate + hook: db_migrate + cmd: + - su discourse -c 'bundle exec rake db:migrate' + - exec: + cd: $home + tag: build + hook: assets_precompile_build + cmd: + - su discourse -c 'bundle exec rake assets:precompile:build' + - exec: + cd: $home + tag: precompile + hook: assets_precompile + cmd: + - su discourse -c 'SKIP_EMBER_CLI_COMPILE=1 bundle exec rake themes:update assets:precompile' + - replace: + tag: precompile + filename: /etc/service/unicorn/run + from: "# before precompile" + to: "PRECOMPILE_ON_BOOT=0" + + - file: + path: /usr/local/bin/discourse + chmod: +x + contents: | + #!/bin/bash + (cd /var/www/discourse && RAILS_ENV=production sudo -H -E -u discourse bundle exec script/discourse "$@") + + - file: + path: /usr/local/bin/rails + chmod: +x + contents: | + #!/bin/bash + (cd /var/www/discourse && RAILS_ENV=production sudo -H -E -u discourse bundle exec script/rails "$@") + + - file: + path: /usr/local/bin/rake + chmod: +x + contents: | + #!/bin/bash + (cd /var/www/discourse && RAILS_ENV=production sudo -H -E -u discourse bundle exec bin/rake "$@") + + - file: + path: /usr/local/bin/rbtrace + chmod: +x + contents: | + #!/bin/bash + (cd /var/www/discourse && RAILS_ENV=production sudo -H -E -u discourse bundle exec rbtrace "$@") + + - file: + path: /usr/local/bin/stackprof + chmod: +x + contents: | + #!/bin/bash + (cd /var/www/discourse && RAILS_ENV=production sudo -H -E -u discourse bundle exec stackprof "$@") + + - file: + path: /etc/update-motd.d/10-web + chmod: +x + contents: | + #!/bin/bash + echo + echo Use: rails, rake or discourse to execute commands in production + echo + + - file: + path: /etc/logrotate.d/rails + contents: | + /shared/log/rails/*.log + { + rotate 7 + dateext + daily + missingok + delaycompress + compress + sharedscripts + postrotate + sv 1 unicorn + endscript + } + + - file: + path: /etc/logrotate.d/caddy + contents: | + /var/log/caddy/*.log { + daily + missingok + rotate 7 + compress + delaycompress + create 0644 caddy caddy + sharedscripts + postrotate + sv 1 caddy + endscript + } + + # move state out of the container this fancy is done to support rapid rebuilds of containers, + # we store anacron and logrotate state outside the container to ensure its maintained across builds + # later move this snipped into an initialization script + # we also ensure all the symlinks we need to /shared are in place in the correct structure + # this allows us to bootstrap on one machine and then run on another + - file: + path: /etc/runit/1.d/00-ensure-links + chmod: +x + contents: | + #!/bin/bash + if [[ ! -L /var/lib/logrotate ]]; then + rm -fr /var/lib/logrotate + mkdir -p /shared/state/logrotate + ln -s /shared/state/logrotate /var/lib/logrotate + fi + if [[ ! -L /var/spool/anacron ]]; then + rm -fr /var/spool/anacron + mkdir -p /shared/state/anacron-spool + ln -s /shared/state/anacron-spool /var/spool/anacron + fi + if [[ ! -d /shared/log/rails ]]; then + mkdir -p /shared/log/rails + chown -R discourse:www-data /shared/log/rails + fi + if [[ ! -d /shared/uploads ]]; then + mkdir -p /shared/uploads + chown -R discourse:www-data /shared/uploads + fi + if [[ ! -d /shared/backups ]]; then + mkdir -p /shared/backups + chown -R discourse:www-data /shared/backups + fi + + rm -rf /shared/tmp/{backups,restores} + mkdir -p /shared/tmp/{backups,restores} + chown -R discourse:www-data /shared/tmp/{backups,restores} + + # Make sure caddy data directory exists and is writable + mkdir -p /var/caddy/data + mkdir -p /var/log/caddy + chown -R caddy:caddy /var/caddy + chown -R caddy:caddy /var/log/caddy + + # Create Caddy test file to verify permissions + echo "CADDY OK" > /var/www/discourse/public/caddy-test.txt + chown discourse:www-data /var/www/discourse/public/caddy-test.txt + + - file: + path: /etc/runit/1.d/01-cleanup-web-pids + chmod: +x + contents: | + #!/bin/bash + /bin/rm -f /var/www/discourse/tmp/pids/*.pid + # change login directory to Discourse home + - file: + path: /root/.bash_profile + chmod: 644 + contents: | + cd $home + + - file: + path: /usr/local/etc/ImageMagick-7/policy.xml + contents: | + + + + + + ]> + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file