🐳 Serve Penpot and the Admin Console from a subpath (#12358)

* 🐳 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 <david.barragan@kaleidos.net>

* 🐳 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 <david.barragan@kaleidos.net>

* 📚 Document subpath deployments in docker-compose.yaml

AI-assisted-by: Claude
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>

* 📚 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 <david.barragan@kaleidos.net>

---------

Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
This commit is contained in:
David Barragán Merino 2026-10-19 15:49:41 +02:00 committed by GitHub
parent e4da0527fc
commit d6e1cb23e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 45 additions and 20 deletions

View File

@ -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(`<DOMAIN_NAME>`)"
# ## When Penpot is served from a subpath, match it too:
# - "traefik.http.routers.penpot-https.rule=Host(`<DOMAIN_NAME>`) && PathPrefix(`/<SUBPATH>`)"
# - "traefik.http.routers.penpot-https.entrypoints=websecure"
# - "traefik.http.routers.penpot-https.tls.certresolver=letsencrypt"
# - "traefik.http.routers.penpot-https.tls=true"

View File

@ -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;
}

View File

@ -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

View File

@ -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) {

View File

@ -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.

View File

@ -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