From d6e1cb23e0283d99a9e684f32f9a7f04a4b7f467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Mon, 19 Oct 2026 15:49:41 +0200 Subject: [PATCH] :whale: Serve Penpot and the Admin Console from a subpath (#12358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :whale: Serve the frontend under the path of PENPOT_PUBLIC_URI Requests below the path are rewritten to the root, so a reverse proxy can forward them unchanged. The Admin Console keeps receiving the original path. AI-assisted-by: Claude Signed-off-by: David Barragán Merino * :whale: Strip the public path before proxying to the Admin Console The Admin Console is served at the root of its own server and applies the public path at runtime, so it needs a matching Admin Console image. AI-assisted-by: Claude Signed-off-by: David Barragán Merino * :books: Document subpath deployments in docker-compose.yaml AI-assisted-by: Claude Signed-off-by: David Barragán Merino * :books: Describe subpath deployments without proxy rewrites The frontend now serves the subpath itself, so the reverse proxy must forward it unchanged. AI-assisted-by: Claude Signed-off-by: David Barragán Merino --------- Signed-off-by: David Barragán Merino --- docker/images/docker-compose.yaml | 5 +++++ ...ginx-admin-console-locations.conf.template | 9 +++++++- docker/images/files/nginx-entrypoint.sh | 22 +++++++++++++++++-- docker/images/files/nginx.conf.template | 6 +++-- docs/technical-guide/configuration.md | 11 +++------- .../technical-guide/getting-started/docker.md | 12 +++++----- 6 files changed, 45 insertions(+), 20 deletions(-) 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