diff --git a/docs/README.md b/docs/README.md index 08ce6096d..b4e6d5c81 100644 --- a/docs/README.md +++ b/docs/README.md @@ -121,6 +121,7 @@ This documentation covers the installation, configuration, and usage of Dokku - ### Migration Guides +- [0.38.0](appendices/0.38.0-migration-guide.md) - [0.37.0](appendices/0.37.0-migration-guide.md) - [0.36.0](appendices/0.36.0-migration-guide.md) - [0.35.0](appendices/0.35.0-migration-guide.md) diff --git a/docs/appendices/0.38.0-migration-guide.md b/docs/appendices/0.38.0-migration-guide.md new file mode 100644 index 000000000..e547325ec --- /dev/null +++ b/docs/appendices/0.38.0-migration-guide.md @@ -0,0 +1,15 @@ +# 0.38.0 Migration Guide + +## Changes + +- Dokku now generates a minimal nginx configuration for apps without running `web` processes (undeployed apps, apps with no `web` process type, or apps with stopped web processes). This configuration returns `502 Bad Gateway` responses, ensuring the app's domain resolves and monitoring tools can detect non-200 status codes. The configuration is automatically replaced with the full proxy configuration once the app is deployed with running `web` processes. See the [nginx documentation](/docs/networking/proxies/nginx.md#nginx-configuration-for-undeployed-apps) for more details. +- Users with custom `nginx.conf.sigil` templates that reference `DOKKU_APP_WEB_LISTENERS` should be aware that this variable may now be empty when the template is rendered for apps without running web processes. Custom templates should handle this case gracefully, for example by using a conditional to serve an error page instead of proxying: + ``` + location / { + {{ if $.DOKKU_APP_WEB_LISTENERS }} + proxy_pass http://{{ $.APP }}-{{ $upstream_port }}; + {{ else }} + return 502; + {{ end }} + } + ``` diff --git a/docs/networking/proxies/nginx.md b/docs/networking/proxies/nginx.md index 330f5fa0f..9e6c63d56 100644 --- a/docs/networking/proxies/nginx.md +++ b/docs/networking/proxies/nginx.md @@ -27,6 +27,21 @@ Nginx will proxy the requests in a [round-robin balancing fashion](http://nginx. > [!NOTE] > Due to how the plugin is implemented, if an app successfully starts up `web` containers but fails to deploy some other containers, nginx may eventually stop routing requests. Users should revert their code in these cases, or manually trigger `dokku proxy:build-config $APP` in order to ensure requests route to the new web containers. +### Nginx Configuration for Undeployed Apps + +> [!IMPORTANT] +> New as of 0.38.0 + +When an app is created but not yet deployed, has no `web` process type, or has no running web processes, Dokku generates a minimal nginx configuration that returns `502 Bad Gateway` responses. This ensures that: + +- The app's domain resolves and returns a non-200 status code, allowing monitoring tools to detect the issue. +- SSL certificate provisioning tools such as letsencrypt can function, as an nginx server is listening on the domain. +- The `nginx.conf.d/` include directory is available for plugin customization. + +The 502 error page includes auto-retry JavaScript that will automatically reload the page when the application becomes available. + +Once the app is deployed with running `web` processes, the placeholder configuration is automatically replaced with the full proxy configuration. + ### Starting nginx > [!IMPORTANT] diff --git a/plugins/nginx-vhosts/functions b/plugins/nginx-vhosts/functions index 1b15cde8e..fac8a85ee 100755 --- a/plugins/nginx-vhosts/functions +++ b/plugins/nginx-vhosts/functions @@ -306,38 +306,50 @@ nginx_build_config() { local PROXY_BUSY_BUFFERS_SIZE="$(fn-nginx-computed-proxy-busy-buffers-size "$APP")" local PROXY_KEEPALIVE="$(fn-nginx-computed-proxy-keepalive "$APP")" - if [[ -z "$DOKKU_APP_LISTENERS" ]]; then + local NGINX_BIND_ADDRESS_IP4="$(fn-nginx-computed-bind-address-ipv4 "$APP")" + local NGINX_BIND_ADDRESS_IP6="$(fn-nginx-computed-bind-address-ipv6 "$APP")" + local NGINX_UNDERSCORE_IN_HEADERS="$(fn-nginx-computed-underscore-in-headers "$APP")" + local PROXY_X_FORWARDED_FOR="$(fn-nginx-computed-x-forwarded-for-value "$APP")" + local PROXY_X_FORWARDED_PORT="$(fn-nginx-computed-x-forwarded-port-value "$APP")" + local PROXY_X_FORWARDED_PROTO="$(fn-nginx-computed-x-forwarded-proto-value "$APP")" + local PROXY_X_FORWARDED_SSL="$(fn-nginx-computed-x-forwarded-ssl "$APP")" + + if [[ -z "$DOKKU_APP_LISTENERS" ]] && [[ -z "$PASSED_LISTEN_IP_PORT" ]]; then dokku_log_warn_quiet "No web listeners specified for $APP" - elif (is_deployed "$APP"); then - if [[ "$(plugn trigger network-get-static-listeners "$APP" "web")" == "" ]]; then - local IMAGE_TAG=$(get_running_image_tag "$APP") - local IMAGE=$(get_deploying_app_image_name "$APP" "$IMAGE_TAG" 2>/dev/null) - if ! verify_image "$IMAGE" 2>/dev/null; then - dokku_log_fail "Missing image for app" + fi + + local IS_DEPLOYED_WITH_LISTENERS=false + if [[ -n "$DOKKU_APP_LISTENERS" ]] && (is_deployed "$APP"); then + IS_DEPLOYED_WITH_LISTENERS=true + fi + + if [[ "$IS_DEPLOYED_WITH_LISTENERS" == "true" ]] || { [[ -z "$DOKKU_APP_LISTENERS" ]] && [[ -z "$PASSED_LISTEN_IP_PORT" ]] && [[ -n "$PROXY_PORT_MAP" ]]; }; then + if [[ "$IS_DEPLOYED_WITH_LISTENERS" == "true" ]]; then + if [[ "$(plugn trigger network-get-static-listeners "$APP" "web")" == "" ]]; then + local IMAGE_TAG=$(get_running_image_tag "$APP") + local IMAGE=$(get_deploying_app_image_name "$APP" "$IMAGE_TAG" 2>/dev/null) + if ! verify_image "$IMAGE" 2>/dev/null; then + dokku_log_fail "Missing image for app" + fi fi fi local NGINX_BUILD_CONFIG_TMP_WORK_DIR=$(mktemp -d "/tmp/dokku-${DOKKU_PID}-${FUNCNAME[0]}.XXXXXX") local NGINX_CONF=$(mktemp --tmpdir="${NGINX_BUILD_CONFIG_TMP_WORK_DIR}" "nginx.conf.XXXXXX") - local CUSTOM_NGINX_TEMPLATE="$NGINX_BUILD_CONFIG_TMP_WORK_DIR/$NGINX_TEMPLATE_NAME" trap "rm -rf '$NGINX_CONF' '$NGINX_BUILD_CONFIG_TMP_WORK_DIR' >/dev/null" RETURN INT TERM EXIT - get_custom_nginx_template "$APP" "$CUSTOM_NGINX_TEMPLATE" 2>/dev/null - if [[ -f "$CUSTOM_NGINX_TEMPLATE" ]]; then - dokku_log_info1 'Overriding default nginx.conf with detected nginx.conf.sigil' - local NGINX_TEMPLATE="$CUSTOM_NGINX_TEMPLATE" - local NGINX_TEMPLATE_SOURCE="app-supplied" + if [[ "$IS_DEPLOYED_WITH_LISTENERS" == "true" ]]; then + local CUSTOM_NGINX_TEMPLATE="$NGINX_BUILD_CONFIG_TMP_WORK_DIR/$NGINX_TEMPLATE_NAME" + get_custom_nginx_template "$APP" "$CUSTOM_NGINX_TEMPLATE" 2>/dev/null + if [[ -f "$CUSTOM_NGINX_TEMPLATE" ]]; then + dokku_log_info1 'Overriding default nginx.conf with detected nginx.conf.sigil' + local NGINX_TEMPLATE="$CUSTOM_NGINX_TEMPLATE" + local NGINX_TEMPLATE_SOURCE="app-supplied" + fi + + eval "$(config_export app "$APP")" fi - local NGINX_BIND_ADDRESS_IP4="$(fn-nginx-computed-bind-address-ipv4 "$APP")" - local NGINX_BIND_ADDRESS_IP6="$(fn-nginx-computed-bind-address-ipv6 "$APP")" - local NGINX_UNDERSCORE_IN_HEADERS="$(fn-nginx-computed-underscore-in-headers "$APP")" - local PROXY_X_FORWARDED_FOR="$(fn-nginx-computed-x-forwarded-for-value "$APP")" - local PROXY_X_FORWARDED_PORT="$(fn-nginx-computed-x-forwarded-port-value "$APP")" - local PROXY_X_FORWARDED_PROTO="$(fn-nginx-computed-x-forwarded-proto-value "$APP")" - local PROXY_X_FORWARDED_SSL="$(fn-nginx-computed-x-forwarded-ssl "$APP")" - - eval "$(config_export app "$APP")" local SIGIL_PARAMS=(-f "$NGINX_TEMPLATE" APP="$APP" DOKKU_ROOT="$DOKKU_ROOT" NOSSL_SERVER_NAME="$NOSSL_SERVER_NAME" # Deprecated: Remove this after a few versions @@ -382,34 +394,40 @@ nginx_build_config() { PROXY_X_FORWARDED_PROTO="$PROXY_X_FORWARDED_PROTO" PROXY_X_FORWARDED_SSL="$PROXY_X_FORWARDED_SSL") - while read -r line || [[ -n "$line" ]]; do - PROC_TYPE=${line%%=*} - LISTENERS="$(plugn trigger network-get-listeners "$APP" "$PROC_TYPE" | xargs)" - UPP_PROC_TYPE="${PROC_TYPE^^}" - UPP_PROC_TYPE="${UPP_PROC_TYPE//-/_}" - SIGIL_PARAMS+=("DOKKU_APP_${UPP_PROC_TYPE}_LISTENERS=$LISTENERS") - done < <(plugn trigger ps-current-scale "$APP") + if [[ "$IS_DEPLOYED_WITH_LISTENERS" == "true" ]]; then + while read -r line || [[ -n "$line" ]]; do + PROC_TYPE=${line%%=*} + LISTENERS="$(plugn trigger network-get-listeners "$APP" "$PROC_TYPE" | xargs)" + UPP_PROC_TYPE="${PROC_TYPE^^}" + UPP_PROC_TYPE="${UPP_PROC_TYPE//-/_}" + SIGIL_PARAMS+=("DOKKU_APP_${UPP_PROC_TYPE}_LISTENERS=$LISTENERS") + done < <(plugn trigger ps-current-scale "$APP") - if grep DOKKU_APP_LISTENERS "$NGINX_TEMPLATE"; then - dokku_log_warn "Deprecated: Usage of DOKKU_APP_LISTENERS within nginx.conf.sigil templates is deprecated in favor of DOKKU_APP_WEB_LISTENERS" + if grep DOKKU_APP_LISTENERS "$NGINX_TEMPLATE"; then + dokku_log_warn "Deprecated: Usage of DOKKU_APP_LISTENERS within nginx.conf.sigil templates is deprecated in favor of DOKKU_APP_WEB_LISTENERS" + fi + + if grep NGINX_SSL_PORT "$NGINX_TEMPLATE"; then + dokku_log_warn "Deprecated: Usage of NGINX_SSL_PORT within nginx.conf.sigil templates is deprecated in favor of PROXY_SSL_PORT" + fi + + if grep NGINX_PORT "$NGINX_TEMPLATE"; then + dokku_log_warn "Deprecated: Usage of NGINX_PORT within nginx.conf.sigil templates is deprecated in favor of PROXY_PORT" + fi + else + SIGIL_PARAMS+=(DOKKU_APP_WEB_LISTENERS="") fi - if grep NGINX_SSL_PORT "$NGINX_TEMPLATE"; then - dokku_log_warn "Deprecated: Usage of NGINX_SSL_PORT within nginx.conf.sigil templates is deprecated in favor of PROXY_SSL_PORT" - fi - - if grep NGINX_PORT "$NGINX_TEMPLATE"; then - dokku_log_warn "Deprecated: Usage of NGINX_PORT within nginx.conf.sigil templates is deprecated in favor of PROXY_PORT" - fi - - # execute sigil template processing xargs -i echo "-----> Configuring {}...(using $NGINX_TEMPLATE_SOURCE template)" <<<"$(echo "${SSL_VHOSTS}" "${NONSSL_VHOSTS}" | tr ' ' '\n' | sort -u)" sigil "${SIGIL_PARAMS[@]}" | cat -s >"$NGINX_CONF" dokku_log_info1 "Creating $SCHEME nginx.conf" + mkdir -p "$DOKKU_ROOT/$APP/nginx.conf.d" mv "$NGINX_CONF" "$DOKKU_ROOT/$APP/nginx.conf" - fn-nginx-vhosts-manage-hsts "$APP" "$SSL_INUSE" + if [[ "$IS_DEPLOYED_WITH_LISTENERS" == "true" ]]; then + fn-nginx-vhosts-manage-hsts "$APP" "$SSL_INUSE" + fi plugn trigger nginx-pre-reload "$APP" "$DOKKU_APP_LISTEN_PORT" "$DOKKU_APP_LISTEN_IP" diff --git a/plugins/nginx-vhosts/post-create b/plugins/nginx-vhosts/post-create new file mode 100755 index 000000000..04f15e915 --- /dev/null +++ b/plugins/nginx-vhosts/post-create @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +source "$PLUGIN_CORE_AVAILABLE_PATH/common/property-functions" +source "$PLUGIN_AVAILABLE_PATH/nginx-vhosts/functions" +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x + +trigger-nginx-vhosts-post-create() { + declare desc="nginx-vhosts post-create trigger" + declare trigger="post-create" + declare APP="$1" + + fn-plugin-property-setup-app "nginx" "$APP" + + if [[ "$(plugn trigger proxy-type "$APP")" != "nginx" ]]; then + return + fi + + if [[ "$(fn-plugin-property-get "nginx" "--global" "proxy-status")" == "stopped" ]]; then + return + fi + + nginx_build_config "$APP" +} + +trigger-nginx-vhosts-post-create "$@" diff --git a/plugins/nginx-vhosts/proxy-build-config b/plugins/nginx-vhosts/proxy-build-config index c9c330e6c..ea95b757f 100755 --- a/plugins/nginx-vhosts/proxy-build-config +++ b/plugins/nginx-vhosts/proxy-build-config @@ -26,7 +26,7 @@ trigger-nginx-vhosts-proxy-build-config() { rm -f "${DOKKU_LIB_ROOT}/data/nginx-vhosts/app-$APP/nginx.conf.sigil" fi else - nginx_clear_config "$APP" + nginx_build_config "$APP" fi } diff --git a/plugins/nginx-vhosts/templates/nginx.conf.sigil b/plugins/nginx-vhosts/templates/nginx.conf.sigil index a7ec0e382..d30b8fe9a 100644 --- a/plugins/nginx-vhosts/templates/nginx.conf.sigil +++ b/plugins/nginx-vhosts/templates/nginx.conf.sigil @@ -26,6 +26,7 @@ server { } {{ else }} location / { +{{ if $.DOKKU_APP_WEB_LISTENERS }} gzip on; gzip_min_length 1100; @@ -51,6 +52,9 @@ server { proxy_set_header X-Forwarded-Proto {{ $.PROXY_X_FORWARDED_PROTO }}; proxy_set_header X-Request-Start $msec; {{ if $.PROXY_X_FORWARDED_SSL }}proxy_set_header X-Forwarded-Ssl {{ $.PROXY_X_FORWARDED_SSL }};{{ end }} +{{ else }} + return 502; +{{ end }} } client_max_body_size {{ $.CLIENT_MAX_BODY_SIZE }}; @@ -103,6 +107,7 @@ server { send_timeout {{ $.SEND_TIMEOUT }}; location / { +{{ if $.DOKKU_APP_WEB_LISTENERS }} gzip on; gzip_min_length 1100; @@ -129,6 +134,9 @@ server { proxy_set_header X-Forwarded-Proto {{ $.PROXY_X_FORWARDED_PROTO }}; proxy_set_header X-Request-Start $msec; {{ if $.PROXY_X_FORWARDED_SSL }}proxy_set_header X-Forwarded-Ssl {{ $.PROXY_X_FORWARDED_SSL }};{{ end }} +{{ else }} + return 502; +{{ end }} } client_max_body_size {{ $.CLIENT_MAX_BODY_SIZE }}; @@ -159,6 +167,7 @@ server { include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf; } {{ else if eq $scheme "grpc"}} +{{ if $.DOKKU_APP_WEB_LISTENERS }} server { {{ if eq $.HTTP2_DIRECTIVE_SUPPORTED "true" }} listen [{{ $.NGINX_BIND_ADDRESS_IP6 }}]:{{ $listen_port }}; @@ -179,7 +188,9 @@ server { client_max_body_size {{ $.CLIENT_MAX_BODY_SIZE }}; include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf; } +{{ end }} {{ else if eq $scheme "grpcs"}} +{{ if $.DOKKU_APP_WEB_LISTENERS }} server { {{ if eq $.HTTP2_DIRECTIVE_SUPPORTED "true" }} listen [{{ $.NGINX_BIND_ADDRESS_IP6 }}]:{{ $listen_port }} ssl; @@ -208,6 +219,7 @@ server { } {{ end }} {{ end }} +{{ end }} {{ if $.DOKKU_APP_WEB_LISTENERS }} {{ range $upstream_port := $.PROXY_UPSTREAM_PORTS | split " " }} diff --git a/tests/unit/apps_2.bats b/tests/unit/apps_2.bats index 5c481bde5..1ab9549da 100644 --- a/tests/unit/apps_2.bats +++ b/tests/unit/apps_2.bats @@ -162,7 +162,7 @@ teardown() { assert_success assert_output "http://$TEST_APP.$DOKKU_DOMAIN" assert_http_localhost_response "http" "$TEST_APP.$DOKKU_DOMAIN" "80" "/hello" - assert_http_localhost_response "http" "great-test-name.dokku.me" "80" "/hello" "" "404" + assert_http_localhost_response "http" "great-test-name.dokku.me" "80" "/hello" "" "502" run /bin/bash -c "dokku --force apps:destroy great-test-name" echo "output: $output" echo "status: $status" diff --git a/tests/unit/config.bats b/tests/unit/config.bats index e9bfb1fce..af758b96a 100644 --- a/tests/unit/config.bats +++ b/tests/unit/config.bats @@ -506,7 +506,7 @@ teardown() { run /bin/bash -c "dokku --app $TEST_APP config:show" echo "output: $output" echo "status: $status" - assert_output "=====> $TEST_APP env vars"$'\nBKEY: true\naKey: true\nbKey: true\nzKey: true' + assert_output "=====> $TEST_APP env vars"$'\nBKEY: true\nDOKKU_PROXY_PORT: 80\naKey: true\nbKey: true\nzKey: true' } @test "(config) config:export" { @@ -518,20 +518,20 @@ teardown() { run /bin/bash -c "dokku config:export --format docker-args $TEST_APP" echo "output: $output" echo "status: $status" - assert_output "--env=BKEY='true' --env=aKey='true' --env=bKey='true' --env=zKey='true'" + assert_output "--env=BKEY='true' --env=DOKKU_PROXY_PORT='80' --env=aKey='true' --env=bKey='true' --env=zKey='true'" run /bin/bash -c "dokku config:export --format shell $TEST_APP" echo "output: $output" echo "status: $status" - assert_output "BKEY='true' aKey='true' bKey='true' zKey='true' " + assert_output "BKEY='true' DOKKU_PROXY_PORT='80' aKey='true' bKey='true' zKey='true' " run /bin/bash -c "dokku config:export --format json $TEST_APP" echo "output: $output" echo "status: $status" - assert_output '{"BKEY":"true","aKey":"true","bKey":"true","zKey":"true"}' + assert_output '{"BKEY":"true","DOKKU_PROXY_PORT":"80","aKey":"true","bKey":"true","zKey":"true"}' run /bin/bash -c "dokku config:export --format json-list $TEST_APP" echo "output: $output" echo "status: $status" - assert_output '[{"name":"BKEY","value":"true"},{"name":"aKey","value":"true"},{"name":"bKey","value":"true"},{"name":"zKey","value":"true"}]' + assert_output '[{"name":"BKEY","value":"true"},{"name":"DOKKU_PROXY_PORT","value":"80"},{"name":"aKey","value":"true"},{"name":"bKey","value":"true"},{"name":"zKey","value":"true"}]' } diff --git a/tests/unit/nginx-vhosts_13.bats b/tests/unit/nginx-vhosts_13.bats new file mode 100644 index 000000000..37845e6fc --- /dev/null +++ b/tests/unit/nginx-vhosts_13.bats @@ -0,0 +1,99 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + global_setup + [[ -f "$DOKKU_ROOT/VHOST" ]] && cp -fp "$DOKKU_ROOT/VHOST" "$DOKKU_ROOT/VHOST.bak" +} + +teardown() { + destroy_app + [[ -f "$DOKKU_ROOT/VHOST.bak" ]] && mv "$DOKKU_ROOT/VHOST.bak" "$DOKKU_ROOT/VHOST" && chown dokku:dokku "$DOKKU_ROOT/VHOST" + global_teardown +} + +@test "(nginx-vhosts) proxy:build-config generates 502 config for undeployed app" { + run create_app + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "test -f $DOKKU_ROOT/$TEST_APP/nginx.conf" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku nginx:show-config $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "return 502" -1 +} + +@test "(nginx-vhosts) proxy:build-config undeployed app returns 502" { + run create_app + echo "output: $output" + echo "status: $status" + assert_success + sleep 2 + + assert_http_localhost_response "http" "${TEST_APP}.${DOKKU_DOMAIN}" "80" "/" "" "502" +} + +@test "(nginx-vhosts) proxy:build-config 502 config replaced after deploy" { + run deploy_app + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku nginx:show-config $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "return 502" 0 + assert_output_contains "proxy_pass" -1 + + assert_http_success "http://${TEST_APP}.${DOKKU_DOMAIN}" +} + +@test "(nginx-vhosts) proxy:build-config domains:add updates 502 config" { + run create_app + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku domains:add $TEST_APP custom.${DOKKU_DOMAIN}" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku nginx:show-config $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "custom.${DOKKU_DOMAIN}" -1 + assert_output_contains "return 502" -1 +} + +@test "(nginx-vhosts) proxy:build-config no nginx.conf when proxy disabled" { + run create_app + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "test -f $DOKKU_ROOT/$TEST_APP/nginx.conf" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku proxy:disable $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "test -f $DOKKU_ROOT/$TEST_APP/nginx.conf" + echo "output: $output" + echo "status: $status" + assert_failure +}