diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index e667bc3049..4c69760e24 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -26,6 +26,9 @@ x-flags: &penpot-flags PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp enable-admin-console +## Public URI where Penpot is reached. To serve Penpot from a subpath, include +## it with a trailing slash, e.g. https://example.com/penpot/. A reverse proxy in +## front must forward the subpath unchanged, without rewriting it. x-uri: &penpot-public-uri PENPOT_PUBLIC_URI: http://localhost:9001 @@ -110,6 +113,8 @@ services: # ## internet with HTTPS using traefik. # - "traefik.http.routers.penpot-https.rule=Host(``)" + # ## When Penpot is served from a subpath, match it too: + # - "traefik.http.routers.penpot-https.rule=Host(``) && PathPrefix(`/`)" # - "traefik.http.routers.penpot-https.entrypoints=websecure" # - "traefik.http.routers.penpot-https.tls.certresolver=letsencrypt" # - "traefik.http.routers.penpot-https.tls=true" diff --git a/docker/images/files/nginx-admin-console-locations.conf.template b/docker/images/files/nginx-admin-console-locations.conf.template index fead3783c9..2b0a4f95b8 100644 --- a/docker/images/files/nginx-admin-console-locations.conf.template +++ b/docker/images/files/nginx-admin-console-locations.conf.template @@ -1,7 +1,14 @@ +# The Admin Console is served at the root of its own server: strip the public +# path and admin-console/ from the original request URI. location /admin-console { + set $admin_console_uri "/"; + if ($request_uri ~ "^${PENPOT_PUBLIC_PATH_REGEX}admin-console(/.*)$") { + set $admin_console_uri $1; + } + proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $http_cf_connecting_ip; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_pass $PENPOT_ADMIN_CONSOLE_URI$request_uri; + proxy_pass $PENPOT_ADMIN_CONSOLE_URI$admin_console_uri; } diff --git a/docker/images/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh index 3ec97403a8..e141db2d3a 100644 --- a/docker/images/files/nginx-entrypoint.sh +++ b/docker/images/files/nginx-entrypoint.sh @@ -51,6 +51,24 @@ update_oidc_name /var/www/app/js/config.js ## Nginx Config ######################################### +# Path of PENPOT_PUBLIC_URI (e.g. /penpot/ for https://example.com/penpot/). +# Requests below it are rewritten to the root, so the proxy in front of this +# server forwards them unchanged and $request_uri keeps the original path. +PENPOT_PUBLIC_PATH="$(sed -E 's|^[A-Za-z][A-Za-z0-9+.-]*://[^/]*||; s|[?#].*$||' <<< "${PENPOT_PUBLIC_URI:-}")" +PENPOT_PUBLIC_PATH="/${PENPOT_PUBLIC_PATH#/}" +export PENPOT_PUBLIC_PATH="${PENPOT_PUBLIC_PATH%/}/" + +# shellcheck disable=SC2016 # literal regex metacharacters +PENPOT_PUBLIC_PATH_REGEX="$(sed -E 's/[][\.*^$(){}+?|]/\\&/g' <<< "$PENPOT_PUBLIC_PATH")" +export PENPOT_PUBLIC_PATH_REGEX + +if [ "$PENPOT_PUBLIC_PATH" != "/" ]; then + export PENPOT_PUBLIC_PATH_DIRECTIVES="location = ${PENPOT_PUBLIC_PATH%/} { absolute_redirect off; return 301 ${PENPOT_PUBLIC_PATH}\$is_args\$args; } + rewrite ^${PENPOT_PUBLIC_PATH_REGEX}(.*)\$ /\$1 last;" +else + export PENPOT_PUBLIC_PATH_DIRECTIVES="" +fi + export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060} export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061} export PENPOT_HTTP_SERVER_MAX_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_BODY_SIZE:-367001600} # Default to 350MiB @@ -58,12 +76,12 @@ export PENPOT_IPV6_LISTEN_DIRECTIVE=${PENPOT_IPV6_LISTEN_DIRECTIVE:-"listen [::] if is_truthy "${PENPOT_DISABLE_IPV6_LISTEN:-}"; then export PENPOT_IPV6_LISTEN_DIRECTIVE="" fi -envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE" \ +envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE,\$PENPOT_IPV6_LISTEN_DIRECTIVE,\$PENPOT_PUBLIC_PATH,\$PENPOT_PUBLIC_PATH_DIRECTIVES" \ < /tmp/nginx.conf.template > /etc/nginx/nginx.conf if [[ $PENPOT_FLAGS == *"enable-admin-console"* ]]; then export PENPOT_ADMIN_CONSOLE_URI=${PENPOT_ADMIN_CONSOLE_URI:-http://penpot-admin-console:3000} - envsubst "\$PENPOT_ADMIN_CONSOLE_URI" \ + envsubst "\$PENPOT_ADMIN_CONSOLE_URI,\$PENPOT_PUBLIC_PATH_REGEX" \ < /tmp/nginx-admin-console-locations.conf.template > /etc/nginx/overrides/server.d/admin-console-locations.conf else rm -f /etc/nginx/overrides/server.d/admin-console-locations.conf diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template index 0451e7a24a..c8800b0503 100644 --- a/docker/images/files/nginx.conf.template +++ b/docker/images/files/nginx.conf.template @@ -102,6 +102,8 @@ http { root /var/www/app/; + ${PENPOT_PUBLIC_PATH_DIRECTIVES} + location @handle_redirect { set $redirect_uri "$upstream_http_location"; set $redirect_host "$upstream_http_x_host"; @@ -167,7 +169,7 @@ http { location /readyz { access_log off; proxy_set_header Host $proxy_host; - proxy_pass $PENPOT_BACKEND_URI$request_uri; + proxy_pass $PENPOT_BACKEND_URI$uri$is_args$args; } location /ws/notifications { @@ -198,7 +200,7 @@ http { } location ~ ^/[^/]+/(.*)$ { - return 301 " /404"; + return 301 " ${PENPOT_PUBLIC_PATH}404"; } if ($penpot_link_preview_agent) { diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index 695a7e3a02..db72a68b0d 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -384,16 +384,11 @@ PENPOT_PUBLIC_URI: https://mycompany.com/penpot/ Your reverse proxy or Ingress must also: -- Route requests under `/penpot/` to the Penpot frontend. -- Remove the `/penpot` prefix before forwarding each request. The frontend - continues to serve its internal routes from `/`. +- Route requests under `/penpot/` to the Penpot frontend without rewriting the + path. The frontend expects to receive the subpath. - Preserve WebSocket upgrade headers for notifications and other services that use WebSockets. -Changing only `PENPOT_PUBLIC_URI` is not enough. Without the matching proxy -rewrite, static assets, API calls, and WebSocket connections will use routes that -the frontend does not recognize. - #### Migrate an existing installation to a subpath Moving an installation from `/` to a subpath does not require a database or @@ -403,7 +398,7 @@ the existing storage. Before changing the public URL: 1. Back up the database, assets, and current deployment configuration. -2. Configure the proxy or Ingress route and prefix rewrite. +2. Configure the proxy or Ingress route for the subpath. 3. Update `PENPOT_PUBLIC_URI` for every Penpot component that uses it. 4. Keep the existing `PENPOT_SECRET_KEY`, database, and asset storage configuration. diff --git a/docs/technical-guide/getting-started/docker.md b/docs/technical-guide/getting-started/docker.md index 3e8f9740a8..d6d9a0ae4b 100644 --- a/docs/technical-guide/getting-started/docker.md +++ b/docs/technical-guide/getting-started/docker.md @@ -325,8 +325,7 @@ First, set the complete public URL in `docker-compose.yaml`: PENPOT_PUBLIC_URI: https://mycompany.com/penpot/ ``` -Then route the subpath to the Penpot frontend and remove the prefix before -forwarding the request: +Then route the subpath to the Penpot frontend, forwarding the path unchanged: ```nginx server { @@ -344,8 +343,6 @@ server { } location /penpot/ { - rewrite ^/penpot/(.*)$ /$1 break; - proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Scheme $scheme; @@ -359,9 +356,10 @@ server { } ``` -This single location also covers the API and WebSocket endpoints because NGINX -forwards them after removing `/penpot`. Keep the trailing slash in -`PENPOT_PUBLIC_URI`. +This single location covers every Penpot endpoint, WebSockets included. Keep +the trailing slash in `PENPOT_PUBLIC_URI`, and do not add a path to `proxy_pass` +(such as `http://localhost:9001/`): NGINX would then replace the `/penpot/` +prefix instead of forwarding it. ### Example with CADDY SERVER