Add Caddy web server template as nginx alternative

Creates a new caddy.template.yml template that replaces nginx with the Caddy web server. Benefits include:

- Automatic HTTPS with Let's Encrypt certificate management
- Basic rate limiting support
- IPv6 dual-stack support (enabled by default in Caddy)
- Advanced compression with zstd preferred over gzip
- Comprehensive security headers
- Modern protocol support (HTTP/2, HTTP/3)
- Simplified configuration with sensible defaults

This implementation uses standard Caddy with all its built-in features without requiring custom modules.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Rafael Silva 2025-04-16 17:58:27 -03:00
parent e42fa9711e
commit ce54bc2f02
2 changed files with 662 additions and 0 deletions

2
mise.toml Normal file
View File

@ -0,0 +1,2 @@
[tools]
node = "latest"

View File

@ -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: |
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policymap [
<!ELEMENT policymap (policy)+>
<!ATTLIST policymap xmlns CDATA #FIXED ''>
<!ELEMENT policy EMPTY>
<!ATTLIST policy xmlns CDATA #FIXED '' domain NMTOKEN #REQUIRED
name NMTOKEN #IMPLIED pattern CDATA #IMPLIED rights NMTOKEN #IMPLIED
stealth NMTOKEN #IMPLIED value CDATA #IMPLIED>
]>
<!--
Configure ImageMagick policies.
Domains include system, delegate, coder, filter, path, or resource.
Rights include none, read, write, execute and all. Use | to combine them,
for example: "read | write" to permit read from, or write to, a path.
Use a glob expression as a pattern.
Suppose we do not want users to process MPEG video images:
<policy domain="delegate" rights="none" pattern="mpeg:decode" />
Here we do not want users reading images from HTTP:
<policy domain="coder" rights="none" pattern="HTTP" />
The /repository file system is restricted to read only. We use a glob
expression to match all paths that start with /repository:
<policy domain="path" rights="read" pattern="/repository/*" />
Lets prevent users from executing any image filters:
<policy domain="filter" rights="none" pattern="*" />
Any large image is cached to disk rather than memory:
<policy domain="resource" name="area" value="1GP"/>
Define arguments for the memory, map, area, width, height and disk resources
with SI prefixes (.e.g 100MB). In addition, resource policies are maximums
for each instance of ImageMagick (e.g. policy memory limit 1GB, -limit 2GB
exceeds policy maximum so memory limit is 1GB).
Rules are processed in order. Here we want to restrict ImageMagick to only
read or write a small subset of proven web-safe image types:
<policy domain="delegate" rights="none" pattern="*" />
<policy domain="filter" rights="none" pattern="*" />
<policy domain="coder" rights="none" pattern="*" />
<policy domain="coder" rights="read|write" pattern="{GIF,JPEG,PNG,WEBP}" />
-->
<policymap>
<!-- <policy domain="system" name="shred" value="2"/> -->
<!-- <policy domain="system" name="precision" value="6"/> -->
<!-- <policy domain="system" name="memory-map" value="anonymous"/> -->
<!-- <policy domain="system" name="max-memory-request" value="256MiB"/> -->
<!-- <policy domain="resource" name="temporary-path" value="/tmp"/> -->
<policy domain="resource" name="memory" value="1GiB"/>
<policy domain="resource" name="map" value="2GiB"/>
<policy domain="resource" name="width" value="64KP"/>
<policy domain="resource" name="height" value="64KP"/>
<!-- <policy domain="resource" name="list-length" value="128"/> -->
<policy domain="resource" name="area" value="4GP"/>
<policy domain="resource" name="disk" value="8GiB"/>
<!-- <policy domain="resource" name="file" value="768"/> -->
<!-- <policy domain="resource" name="thread" value="4"/> -->
<!-- <policy domain="resource" name="throttle" value="0"/> -->
<!-- <policy domain="resource" name="time" value="3600"/> -->
<!-- <policy domain="coder" rights="none" pattern="MVG" /> -->
<policy domain="module" rights="none" pattern="{PS,PS2,PS3,EPS,XPS}" />
<!-- <policy domain="delegate" rights="none" pattern="HTTPS" /> -->
<!-- <policy domain="path" rights="none" pattern="@*" /> -->
<!-- <policy domain="cache" name="memory-map" value="anonymous"/> -->
<!-- <policy domain="cache" name="synchronize" value="True"/> -->
<!-- <policy domain="cache" name="shared-secret" value="passphrase" stealth="true"/> -->
</policymap>