diff --git a/image/base/etc/nginx/conf.d/discourse.conf b/image/base/etc/nginx/conf.d/discourse.conf new file mode 100644 index 000000000..612cffa8e --- /dev/null +++ b/image/base/etc/nginx/conf.d/discourse.conf @@ -0,0 +1,289 @@ +# 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; +} + +include conf.d/outlets/before-server/*.conf; + +server { + access_log /var/log/nginx/access.log log_discourse; + + include conf.d/outlets/server/*.conf; + + 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; + + server_name _; + 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 10m; + + # 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_set_header X-Sendfile-Type ""; + proxy_set_header X-Accel-Mapping ""; + 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_set_header X-Sendfile-Type ""; + proxy_set_header X-Accel-Mapping ""; + 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_set_header X-Sendfile-Type ""; + proxy_set_header X-Accel-Mapping ""; + 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/(?.+)$ { + 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|extra-locales/(mf|overrides)) { + 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 ""; + proxy_set_header X-Accel-Mapping ""; + + # 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_set_header X-Sendfile-Type ""; + proxy_set_header X-Accel-Mapping ""; + 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 { + include conf.d/outlets/discourse/*.conf; + + 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_set_header X-Sendfile-Type ""; + proxy_set_header X-Accel-Mapping ""; + proxy_pass http://discourse; + } +} diff --git a/image/base/etc/nginx/conf.d/outlets/before-server/.gitkeep b/image/base/etc/nginx/conf.d/outlets/before-server/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/image/base/etc/nginx/conf.d/outlets/discourse/.gitkeep b/image/base/etc/nginx/conf.d/outlets/discourse/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/image/base/etc/nginx/conf.d/outlets/server/.gitkeep b/image/base/etc/nginx/conf.d/outlets/server/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/image/base/etc/nginx/conf.d/outlets/server/http.conf b/image/base/etc/nginx/conf.d/outlets/server/http.conf new file mode 100644 index 000000000..56b9f1c80 --- /dev/null +++ b/image/base/etc/nginx/conf.d/outlets/server/http.conf @@ -0,0 +1 @@ +listen 80; diff --git a/samples/standalone.yml b/samples/standalone.yml index e6d946f03..a4c3bd5d6 100644 --- a/samples/standalone.yml +++ b/samples/standalone.yml @@ -11,8 +11,6 @@ templates: - "templates/postgres.template.yml" - "templates/redis.template.yml" - "templates/web.template.yml" - ## Uncomment the next line to enable the IPv6 listener - #- "templates/web.ipv6.template.yml" - "templates/web.ratelimited.template.yml" ## Uncomment these two lines if you wish to add Lets Encrypt (https) #- "templates/web.ssl.template.yml" diff --git a/samples/web_only.yml b/samples/web_only.yml index c4753dde0..d335c847e 100644 --- a/samples/web_only.yml +++ b/samples/web_only.yml @@ -3,8 +3,6 @@ templates: - "templates/web.template.yml" - ## Uncomment the next line to enable the IPv6 listener - #- "templates/web.ipv6.template.yml" - "templates/web.ratelimited.template.yml" ## Uncomment these two lines if you wish to add Lets Encrypt (https) #- "templates/web.ssl.template.yml" diff --git a/templates/offline-page.template.yml b/templates/offline-page.template.yml index 9bcd7723f..6741f07be 100644 --- a/templates/offline-page.template.yml +++ b/templates/offline-page.template.yml @@ -7,17 +7,14 @@ params: offline_page_repository: https://github.com/discourse/discourse-offline-page.git run: - - replace: - filename: "/etc/nginx/conf.d/discourse.conf" - global: true - from: /server.+{/ - to: | - server { - error_page 502 /error_page.html; - location /error_page.html { - root /var/www/discourse-offline-page/html; - internal; - } + - file: + path: "/etc/nginx/conf.d/outlets/server/offline-page.conf" + contents: | + error_page 502 /error_page.html; + location /error_page.html { + root /var/www/discourse-offline-page/html; + internal; + } - exec: cmd: git clone $offline_page_repository /var/www/discourse-offline-page diff --git a/templates/sshd.template.yml b/templates/sshd.template.yml index d9bf57c2a..b8f3f1587 100644 --- a/templates/sshd.template.yml +++ b/templates/sshd.template.yml @@ -1,2 +1,6 @@ # This file is deprecated; you can remove it from your app.yml +# TODO(2026-01-01): Remove this file run: + - exec: |- + echo "Deprecation warning: sshd is no longer supported" + echo "Remove templates/sshd.template.yml from your containers/*.yml files" diff --git a/templates/web.ipv6.template.yml b/templates/web.ipv6.template.yml index bf589fe24..c429fc31c 100644 --- a/templates/web.ipv6.template.yml +++ b/templates/web.ipv6.template.yml @@ -1,8 +1,6 @@ +# This file is deprecated; you can remove it from your app.yml +# TODO(2026-01-01): Remove this file run: - - exec: echo "Enabling IPv6 listener" - - replace: - filename: "/etc/nginx/conf.d/discourse.conf" - from: listen 80; - to: | - listen 80; - listen [::]:80; + - exec: |- + echo "Deprecation warning: IPv6 is enabled by default when possible" + echo "Remove templates/web.ipv6.template.yml from your containers/*.yml files" diff --git a/templates/web.letsencrypt.ssl.template.yml b/templates/web.letsencrypt.ssl.template.yml index ba5f55178..ff96657e8 100644 --- a/templates/web.letsencrypt.ssl.template.yml +++ b/templates/web.letsencrypt.ssl.template.yml @@ -106,13 +106,6 @@ hooks: /usr/sbin/nginx -c /etc/nginx/letsencrypt.conf -s stop - - replace: - filename: "/etc/nginx/conf.d/discourse.conf" - from: /ssl_certificate.+/ - to: | - ssl_certificate /shared/ssl/$$ENV_DISCOURSE_HOSTNAME.cer; - ssl_certificate /shared/ssl/$$ENV_DISCOURSE_HOSTNAME_ecc.cer; - - replace: filename: /shared/letsencrypt/account.conf from: /#?ACCOUNT_EMAIL=.+/ @@ -120,14 +113,15 @@ hooks: ACCOUNT_EMAIL=$$ENV_LETSENCRYPT_ACCOUNT_EMAIL - replace: - filename: "/etc/nginx/conf.d/discourse.conf" - from: /ssl_certificate_key.+/ + filename: "/etc/nginx/conf.d/outlets/server/https.conf" + from: /ssl_certificate.+/ to: | - ssl_certificate_key /shared/ssl/$$ENV_DISCOURSE_HOSTNAME.key; - ssl_certificate_key /shared/ssl/$$ENV_DISCOURSE_HOSTNAME_ecc.key; + ssl_certificate /shared/ssl/$$ENV_DISCOURSE_HOSTNAME.cer; + ssl_certificate /shared/ssl/$$ENV_DISCOURSE_HOSTNAME_ecc.cer; - replace: - filename: "/etc/nginx/conf.d/discourse.conf" - from: /add_header.+/ + filename: "/etc/nginx/conf.d/outlets/server/https.conf" + from: /ssl_certificate_key.+/ to: | - add_header Strict-Transport-Security 'max-age=63072000'; + ssl_certificate_key /shared/ssl/$$ENV_DISCOURSE_HOSTNAME.key; + ssl_certificate_key /shared/ssl/$$ENV_DISCOURSE_HOSTNAME_ecc.key; diff --git a/templates/web.ratelimited.template.yml b/templates/web.ratelimited.template.yml index ae9cd636c..ca4cd4b2c 100644 --- a/templates/web.ratelimited.template.yml +++ b/templates/web.ratelimited.template.yml @@ -6,21 +6,18 @@ params: conn_per_ip: 20 run: - - replace: - filename: "/etc/nginx/conf.d/discourse.conf" - from: /server.+{/ - to: | + - file: + path: "/etc/nginx/conf.d/outlets/before-server/ratelimited.conf" + contents: | limit_req_zone $binary_remote_addr zone=flood:10m rate=$reqs_per_secondr/s; limit_req_zone $binary_remote_addr zone=bot:10m rate=$reqs_per_minuter/m; limit_req_status 429; limit_conn_zone $binary_remote_addr zone=connperip:10m; limit_conn_status 429; - server { - - replace: - filename: "/etc/nginx/conf.d/discourse.conf" - from: "/location @discourse {/" - to: | - location @discourse { + + - file: + path: "/etc/nginx/conf.d/outlets/discourse/ratelimited.conf" + contents: | limit_conn connperip $conn_per_ip; limit_req zone=flood burst=$burst_per_second nodelay; limit_req zone=bot burst=$burst_per_minute nodelay; diff --git a/templates/web.socketed.template.yml b/templates/web.socketed.template.yml index ff4f87a1b..28787814d 100644 --- a/templates/web.socketed.template.yml +++ b/templates/web.socketed.template.yml @@ -12,14 +12,14 @@ run: #!/bin/bash rm -rf /shared/nginx.http*.sock - replace: - filename: "/etc/nginx/conf.d/discourse.conf" - from: /listen 80;/ + filename: "/etc/nginx/conf.d/outlets/server/http.conf" + from: /listen 80;(\nlisten \[::\]:80;)?/ to: | listen unix:/shared/nginx.http.sock; set_real_ip_from unix:; - replace: - filename: "/etc/nginx/conf.d/discourse.conf" - from: /listen 443 ssl http2;/ + filename: "/etc/nginx/conf.d/outlets/server/https.conf" + from: /listen 443 ssl;(\nlisten \[::\]:443 ssl;)?/ to: | - listen unix:/shared/nginx.https.sock ssl http2; + listen unix:/shared/nginx.https.sock ssl; set_real_ip_from unix:; diff --git a/templates/web.ssl.template.yml b/templates/web.ssl.template.yml index cc6a66fe0..9a3fc0b44 100644 --- a/templates/web.ssl.template.yml +++ b/templates/web.ssl.template.yml @@ -1,58 +1,51 @@ run: - exec: - cmd: - - "mkdir -p /shared/ssl/" - - replace: - filename: "/etc/nginx/conf.d/discourse.conf" - from: /server.+{/ - to: | - server { - listen 80; - return 301 https://$$ENV_DISCOURSE_HOSTNAME$request_uri; - } - server { - - replace: - filename: "/etc/nginx/conf.d/discourse.conf" - from: /listen 80;\s+gzip on;/m - to: | - listen 443 ssl; - http2 on; - SSL_TEMPLATE_SSL_BLOCK - - replace: - filename: "/etc/nginx/conf.d/discourse.conf" - from: /listen 80;\s+listen \[::\]:80;\s+gzip on;/m - to: | - listen 443 ssl; - listen [::]:443 ssl; - http2 on; - SSL_TEMPLATE_SSL_BLOCK - - replace: - hook: ssl - filename: "/etc/nginx/conf.d/discourse.conf" - from: /SSL_TEMPLATE_SSL_BLOCK/ - to: | - - ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; - ssl_prefer_server_ciphers off; - - ssl_certificate /shared/ssl/ssl.crt; - ssl_certificate_key /shared/ssl/ssl.key; - - ssl_session_tickets off; - ssl_session_timeout 1d; - ssl_session_cache shared:SSL:1m; - - gzip on; - - add_header Strict-Transport-Security 'max-age=31536000'; # remember the certificate for a year and automatically connect to HTTPS for this domain - - if ($http_host != $$ENV_DISCOURSE_HOSTNAME) { - rewrite (.*) https://$$ENV_DISCOURSE_HOSTNAME$1 permanent; - } - - replace: - filename: "/etc/nginx/conf.d/discourse.conf" - from: "location @discourse {" - to: | - location @discourse { - add_header Strict-Transport-Security 'max-age=31536000'; # remember the certificate for a year and automatically connect to HTTPS for this domain + cmd: + - "mkdir -p /shared/ssl/" + + - file: + path: "/etc/nginx/conf.d/outlets/before-server/redirect-http-to-https.conf" + contents: | + server { + listen 80; + return 301 https://$$ENV_DISCOURSE_HOSTNAME$request_uri; + } + + - exec: rm /etc/nginx/conf.d/outlets/server/http.conf + + - file: + hook: ssl + path: "/etc/nginx/conf.d/outlets/server/https.conf" + contents: | + listen 443 ssl; + http2 on; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + + ssl_certificate /shared/ssl/ssl.crt; + ssl_certificate_key /shared/ssl/ssl.key; + + ssl_session_tickets off; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:1m; + + add_header Strict-Transport-Security 'max-age=31536000'; + + if ($http_host != $$ENV_DISCOURSE_HOSTNAME) { + rewrite (.*) https://$$ENV_DISCOURSE_HOSTNAME$1 permanent; + } + + - file: + path: "/etc/nginx/conf.d/outlets/discourse/https.conf" + contents: | + add_header Strict-Transport-Security 'max-age=31536000'; + + - exec: + cmd: + - |- + if [ -f "/proc/net/if_inet6" ] ; then + sed -i 's/listen 80;/listen 80;\nlisten [::]:80;/g' /etc/nginx/conf.d/outlets/before-server/redirect-http-to-https.conf + sed -i 's/listen 443 ssl;/listen 443 ssl;\nlisten [::]:443 ssl;/g' /etc/nginx/conf.d/outlets/server/https.conf + fi diff --git a/templates/web.template.yml b/templates/web.template.yml index 9b541c9c2..f0bf06a0b 100644 --- a/templates/web.template.yml +++ b/templates/web.template.yml @@ -132,7 +132,6 @@ run: - exec: cmd: - - "cp $home/config/nginx.sample.conf /etc/nginx/conf.d/discourse.conf" - "rm /etc/nginx/sites-enabled/default" - "mkdir -p /var/nginx/cache" @@ -142,26 +141,21 @@ run: to: daemon off; - replace: - filename: "/etc/nginx/conf.d/discourse.conf" - from: /upstream[^\}]+\}/m - to: "upstream discourse { - server 127.0.0.1:3000; - }" - - - replace: - filename: "/etc/nginx/conf.d/discourse.conf" - from: /server_name.+$/ - to: server_name _ ; + filename: "/etc/nginx/nginx.conf" + from: /worker_connections.+$/ + to: worker_connections $nginx_worker_connections; - replace: filename: "/etc/nginx/conf.d/discourse.conf" from: /client_max_body_size.+$/ - to: client_max_body_size $upload_size ; + to: client_max_body_size $upload_size; - - replace: - filename: "/etc/nginx/nginx.conf" - from: /worker_connections.+$/ - to: worker_connections $nginx_worker_connections ; + - exec: + cmd: + - |- + if [ -f "/proc/net/if_inet6" ]; then + sed -i 's/listen 80;/listen 80;\nlisten [::]:80;/g' /etc/nginx/conf.d/outlets/server/http.conf + fi - exec: cmd: echo "done configuring web" @@ -222,6 +216,7 @@ run: 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