Edit: Resolved on my end after rebuilding the whole stack from scratch—not sure exactly which specific change fixed this one (IMO crowdsec’s appsec), but wanted to update the thread rather than leave a misleading edit.
Update: it now works fine behind a Docker bridge network too—see this follow-up post for the compose/Caddyfile changes.
Hi,
I am having an issue with my self-hosted Penpot setup (v2.16) running behind Caddy as a reverse proxy.
Despite having configured PENPOT_PUBLIC_URI: in my compose.yml for both the frontend and backend services, the application inside the browser keeps trying to fetch API queries from http://localhost:9001/.
My setup details:
xxxxx.xxxxxxxxx.xxx {
import crowdsec_bouncer
reverse_proxy penpot-frontend:8080
}
From client’s console
logging.cljc:294 INF \[app.main\] version="2.16.2", public-uri="http://localhost:9001/"
localhost:9001/api/main/methods/get-enabled-flags:1 Failed to load resource: net::ERR_CONNECTION_REFUSED
What I have tried so far:
-
Hardcoding PENPOT_PUBLIC_URI: directly under the environment variables of penpot-frontend (bypassing YAML anchors).
-
Running docker compose down && docker volume rm <assets_volume> && docker compose up -d to clear potential configuration caches in the frontend assets.
-
Forcing container recreation with --force-recreate.
The public-uri remains stubbornly hardcoded to http://localhost:9001/ inside the generated frontend files.
How can I force the frontend container to properly inject and respect the PENPOT_PUBLIC_URI variable at startup in this reverse proxy architecture?
Thank you for your help!
Edit: I think I’ve found the culprit (me, big surprise); I forgot to change PENPOT_PUBLIC_URI: http://penpot-frontend:8080 in the exporter service.
→ In this doc for the public URI, it say If you're using the official docker-compose.yml you only need to configure the PENPOT_PUBLIC_URI envvar in the top of the file., so I’m not sure, and there is this comment in the compose file
environment:
<< : [*penpot-secret-key]
# Don't touch it; this uses an internal docker network to
# communicate with the frontend.
PENPOT_PUBLIC_URI: http://penpot-frontend:8080
So… it’s not the issue?
Ever since changing the public URI for the export service, it’s still not working.
Just got a lot of errors of this kind from the client console
http.cljs:117 GET http://localhost:9001/api/main/methods/get-profile net::ERR_CONNECTION_REFUSED
It looks like my modifications in the compose file aren’t even used.
Edit: I’ve manually put the PENPOT_PUBLIC_URI on each service and recreate all the stack, but still no noticable change, still on localhost in the console.
The app ask for local permission when I call it from private web window. Very strange, everything look like there is no difference whatever I set in my compose yml
x-flags: &penpot-flags
PENPOT_FLAGS: enable-smtp enable-prepl-server enable-mcp
x-uri: &penpot-public-uri
PENPOT_PUBLIC_URI: https://xxxxxxx.xxxxxxxx.xxx
x-body-size: &penpot-http-body-size
PENPOT_HTTP_SERVER_MAX_BODY_SIZE: 367001600
PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE: 367001600
x-secret-key: &penpot-secret-key
PENPOT_SECRET_KEY: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
networks:
penpot:
caddy_network:
external: true
volumes:
penpot_postgres_v15:
penpot_assets:
services:
penpot-frontend:
image: "penpotapp/frontend:${PENPOT_VERSION:-2.16}"
restart: always
volumes:
- penpot_assets:/opt/data/assets
depends_on:
- penpot-backend
- penpot-exporter
- penpot-mcp
networks:
- penpot
- caddy_network
environment:
<< : [*penpot-flags, *penpot-http-body-size]
PENPOT_PUBLIC_URI: https://xxxxxxx.xxxxxxxx.xxx
penpot-backend:
image: "penpotapp/backend:${PENPOT_VERSION:-2.16}"
restart: always
volumes:
- penpot_assets:/opt/data/assets
depends_on:
penpot-postgres:
condition: service_healthy
penpot-valkey:
condition: service_healthy
networks:
- penpot
- caddy_network
environment:
<<: [*penpot-flags, *penpot-http-body-size]
PENPOT_PUBLIC_URI: https://xxxxxxx.xxxxxxxx.xxx
PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot
PENPOT_DATABASE_USERNAME: penpot
PENPOT_DATABASE_PASSWORD: penpot
PENPOT_REDIS_URI: redis://penpot-valkey/0
AWS_ACCESS_KEY_ID: xxxxxxxxxxxxxxxxxxxxxxxxx
AWS_SECRET_ACCESS_KEY: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
PENPOT_OBJECTS_STORAGE_BACKEND: s3
PENPOT_OBJECTS_STORAGE_S3_ENDPOINT: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
PENPOT_OBJECTS_STORAGE_S3_BUCKET: "penpot-assets"
PENPOT_TELEMETRY_ENABLED: "true"
PENPOT_TELEMETRY_REFERER: compose
PENPOT_SMTP_DEFAULT_FROM: xxxxxxxxxxxxxxxxxxxxxxxxxx
PENPOT_SMTP_DEFAULT_REPLY_TO: xxxxxxxxxxxxxxxxxxxxxxxxx
PENPOT_SMTP_HOST: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
PENPOT_SMTP_PORT: 587
PENPOT_SMTP_USERNAME: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
PENPOT_SMTP_PASSWORD: xxxxxxxxxxxxxxxxxxx
PENPOT_SMTP_TLS: "true"
PENPOT_SMTP_SSL: "false"
penpot-mcp:
image: "penpotapp/mcp:${PENPOT_VERSION:-2.16}"
restart: always
networks:
- penpot
penpot-exporter:
image: "penpotapp/exporter:${PENPOT_VERSION:-2.16}"
restart: always
depends_on:
penpot-valkey:
condition: service_healthy
networks:
- penpot
environment:
<< : [*penpot-secret-key]
# Don't touch it; this uses an internal docker network to
# communicate with the frontend.
PENPOT_PUBLIC_URI: https://xxxxxxx.xxxxxxxx.xxx
PENPOT_REDIS_URI: redis://penpot-valkey/0
penpot-postgres:
image: "postgres:15"
restart: always
stop_signal: SIGINT
healthcheck:
test: ["CMD-SHELL", "pg_isready -U penpot"]
interval: 2s
timeout: 10s
retries: 5
start_period: 2s
volumes:
- penpot_postgres_v15:/var/lib/postgresql/data
networks:
- penpot
environment:
- POSTGRES_INITDB_ARGS=--data-checksums
- POSTGRES_DB=penpot
- POSTGRES_USER=penpot
- POSTGRES_PASSWORD=penpot
penpot-valkey:
image: valkey/valkey:8.1
restart: always
healthcheck:
test: ["CMD-SHELL", "valkey-cli ping | grep PONG"]
interval: 1s
timeout: 3s
retries: 5
start_period: 3s
networks:
- penpot
environment:
- VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu
I’m pretty sure it’s a configuration issue on my end rather than a bug, but I’m completely lost.
I’ll try to remake everything from the beginning and restart my server, maybe it’ll change something.
Was this setup working correctly before?
No, it’s a fresh install. I’m trying to deploy it for the first time using Caddy as a reverse proxy and isolating the containers in a Docker bridge network without exposing ports directly. So far, I haven’t been able to get past this localhost:9001 issue.
If I manage to find the workaround on my side, I’ll be sure to share it here for anyone else using Caddy!
Have you tried rebuilding the containers to make sure you’re using the latest configuration?
docker compose up -d --pull always --force-recreate
2 Likes
Hello, sadly it changes nothing.
I’m trying to install it without any network bridge, and I have strange errors with Content-Type: application/transit+json.
Edit: It’s not related to Penpot; it’s a security rule from the CrowdSec appsec. It’s the rule 920420 that rejects these requests.
I’ve finally made it working, currently with exposed ports, if I successfully use a bridge, I’ll post how I’ve made it later.
For now, I use crowdsec appsec, crowdsec cloudflare bouncer, crowdsec firewall bouncer, caddy compiled with crowdsec caddy bouncer, and cloudflare R2 as storage.
Two gotchas that took me a while to figure out, in case they help someone else:
- The default CRS rules
920420 and 943120 both cause issues with Penpot. 920420 blocks Penpot’s Content-Type: application/transit+json on all API calls (register, login, etc.), resulting in a 403 with no clear indication of the cause. 943120 triggers a false positive on Penpot’s WebSocket notifications endpoint (/ws/notifications?session-id=...), which legitimately lacks a Referer header during the handshake, flagging it as a “Possible Session Fixation Attack.” I had to add scoped exceptions for both in my AppSec custom-config.yaml config:
name: custom/custom-config
inband_rules:
- crowdsecurity/base-config
- crowdsecurity/vpatch-*
- crowdsecurity/generic-*
- crowdsecurity/crs
outofband_rules:
- crowdsecurity/appsec-generic-test
default_remediation: ban
blocked_http_code: 403
on_match:
- filter: |
req.Host == "penpot.mywebsite.com" &&
any(evt.Appsec.MatchedRules, #.name == "native_rule:920420")
apply:
- SetRemediation("allow")
- CancelAlert()
- CancelEvent()
- filter: |
req.Host == "penpot.mywebsite.com" &&
any(evt.Appsec.MatchedRules, #.name == "native_rule:943120")
apply:
- SetRemediation("allow")
- CancelAlert()
- CancelEvent()
- If you use Cloudflare R2 for storage, Penpot defaults to region
eu-central-1 when PENPOT_OBJECTS_STORAGE_S3_REGION isn’t set — which R2 rejects, breaking thumbnail generation silently. You need to set it explicitly:*
PENPOT_OBJECTS_STORAGE_S3_REGION: auto
Here is my full compose.yml:
x-flags: &penpot-flags
PENPOT_FLAGS: enable-smtp enable-prepl-server enable-mcp
x-uri: &penpot-public-uri
PENPOT_PUBLIC_URI: https://penpot.mywebsite.com
x-body-size: &penpot-http-body-size
PENPOT_HTTP_SERVER_MAX_BODY_SIZE: 367001600
PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE: 367001600
x-secret-key: &penpot-secret-key
PENPOT_SECRET_KEY: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
networks:
penpot:
volumes:
penpot_postgres_v15:
penpot_assets:
services:
penpot-frontend:
image: "penpotapp/frontend:${PENPOT_VERSION:-2.16}"
restart: always
ports:
- 9001:8080
volumes:
- penpot_assets:/opt/data/assets
depends_on:
- penpot-backend
- penpot-exporter
- penpot-mcp
networks:
- penpot
environment:
<<: [*penpot-flags, *penpot-http-body-size, *penpot-public-uri]
penpot-backend:
image: "penpotapp/backend:${PENPOT_VERSION:-2.16}"
restart: always
volumes:
- penpot_assets:/opt/data/assets
depends_on:
penpot-postgres:
condition: service_healthy
penpot-valkey:
condition: service_healthy
networks:
- penpot
environment:
<<:
[
*penpot-flags,
*penpot-public-uri,
*penpot-http-body-size,
*penpot-secret-key,
]
PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot
PENPOT_DATABASE_USERNAME: penpot
PENPOT_DATABASE_PASSWORD: penpot
PENPOT_REDIS_URI: redis://penpot-valkey/0
AWS_ACCESS_KEY_ID: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
AWS_SECRET_ACCESS_KEY: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
PENPOT_OBJECTS_STORAGE_BACKEND: s3
PENPOT_OBJECTS_STORAGE_S3_ENDPOINT: https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.r2.cloudflarestorage.com
PENPOT_OBJECTS_STORAGE_S3_BUCKET: penpot-assets
PENPOT_OBJECTS_STORAGE_S3_REGION: auto
PENPOT_TELEMETRY_ENABLED: "true"
PENPOT_TELEMETRY_REFERER: compose
PENPOT_SMTP_DEFAULT_FROM: noreply@mondomaine.com
PENPOT_SMTP_DEFAULT_REPLY_TO: noreply@mondomaine.com
PENPOT_SMTP_HOST: monserveur.mxrouting.net
PENPOT_SMTP_PORT: 465
PENPOT_SMTP_USERNAME: noreply@mondomaine.com
PENPOT_SMTP_PASSWORD: xxxxxxxxxxxxxx
PENPOT_SMTP_TLS: "true"
PENPOT_SMTP_SSL: "true"
penpot-mcp:
image: "penpotapp/mcp:${PENPOT_VERSION:-2.16}"
restart: always
networks:
- penpot
penpot-exporter:
image: "penpotapp/exporter:${PENPOT_VERSION:-2.16}"
restart: always
depends_on:
penpot-valkey:
condition: service_healthy
networks:
- penpot
environment:
<<: [*penpot-secret-key, *penpot-public-uri]
PENPOT_INTERNAL_URI: http://penpot-frontend:8080
PENPOT_REDIS_URI: redis://penpot-valkey/0
penpot-postgres:
image: "postgres:15"
restart: always
stop_signal: SIGINT
healthcheck:
test: ["CMD-SHELL", "pg_isready -U penpot"]
interval: 2s
timeout: 10s
retries: 5
start_period: 2s
volumes:
- penpot_postgres_v15:/var/lib/postgresql/data
networks:
- penpot
environment:
- POSTGRES_INITDB_ARGS=--data-checksums
- POSTGRES_DB=penpot
- POSTGRES_USER=penpot
- POSTGRES_PASSWORD=penpot
penpot-valkey:
image: valkey/valkey:8.1
restart: always
healthcheck:
test: ["CMD-SHELL", "valkey-cli ping | grep PONG"]
interval: 1s
timeout: 3s
retries: 5
start_period: 3s
networks:
- penpot
environment:
- VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu
Hope it helps others.
If I manage to get it working through a Docker bridge network instead of exposed ports, I’ll share the updated compose.yml here.
1 Like
I managed to get it working through a Docker bridge network (caddy_network shared with Caddy) instead of exposed ports, as promised.
The changes were straightforward: on the Penpot side, add the shared caddy_network to penpot-frontend’s networks list and remove its ports: mapping; then just update the reverse_proxy line in the Caddyfile to point at penpot-frontend:8080 (the Docker service name) instead of an IP:port.
One thing worth flagging: after switching to the bridge, I hit a new CrowdSec AppSec false positive that I hadn’t seen before — rule 943120 (“Possible Session Fixation Attack”) blocking Penpot’s WebSocket notifications endpoint (/ws/notifications?session-id=...), which legitimately lacks a Referer header during the handshake.
Note for CrowdSec’s users
I’m not fully sure whether this is specific to the bridge setup or something I just hadn’t triggered yet with exposed ports — but if you’re running CrowdSec AppSec with the CRS ruleset, watch out for it. The workaround (a scoped on_match exception) is in my previous post above, alongside the 920420 one.
Happy to share the full updated compose.yml + Caddyfile snippet if anyone wants it.
Here is the compose.yml
x-flags: &penpot-flags
PENPOT_FLAGS: enable-smtp enable-prepl-server enable-mcp
x-uri: &penpot-public-uri
PENPOT_PUBLIC_URI: https://penpot.mywebsite.com
x-body-size: &penpot-http-body-size
PENPOT_HTTP_SERVER_MAX_BODY_SIZE: 367001600
PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE: 367001600
x-secret-key: &penpot-secret-key
PENPOT_SECRET_KEY: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
networks:
penpot:
caddy_network:
external: true
volumes:
penpot_postgres_v15:
penpot_assets:
services:
penpot-frontend:
image: "penpotapp/frontend:${PENPOT_VERSION:-2.16}"
restart: always
volumes:
- penpot_assets:/opt/data/assets
depends_on:
- penpot-backend
- penpot-exporter
- penpot-mcp
networks:
- penpot
- caddy_network
environment:
<< : [*penpot-flags, *penpot-http-body-size, *penpot-public-uri]
penpot-backend:
image: "penpotapp/backend:${PENPOT_VERSION:-2.16}"
restart: always
volumes:
- penpot_assets:/opt/data/assets
depends_on:
penpot-postgres:
condition: service_healthy
penpot-valkey:
condition: service_healthy
networks:
- penpot
environment:
<< : [*penpot-flags, *penpot-public-uri, *penpot-http-body-size, *penpot-secret-key]
PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot
PENPOT_DATABASE_USERNAME: penpot
PENPOT_DATABASE_PASSWORD: penpot
PENPOT_REDIS_URI: redis://penpot-valkey/0
AWS_ACCESS_KEY_ID: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
AWS_SECRET_ACCESS_KEY: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
PENPOT_OBJECTS_STORAGE_BACKEND: s3
PENPOT_OBJECTS_STORAGE_S3_ENDPOINT: https://xxxxxxxxxxxxxxxx.r2.cloudflarestorage.com
PENPOT_OBJECTS_STORAGE_S3_BUCKET: penpot-assets
PENPOT_OBJECTS_STORAGE_S3_REGION: auto
PENPOT_TELEMETRY_ENABLED: "true"
PENPOT_TELEMETRY_REFERER: compose
PENPOT_SMTP_DEFAULT_FROM: noreply@mywebsite.com
PENPOT_SMTP_DEFAULT_REPLY_TO: noreply@mywebsite.com
PENPOT_SMTP_HOST: mymxserver.mxrouting.net
PENPOT_SMTP_PORT: 465
PENPOT_SMTP_USERNAME: noreply@mywebsite.com
PENPOT_SMTP_PASSWORD: xxxxxxxxxxxxxxxxx
PENPOT_SMTP_TLS: "true"
PENPOT_SMTP_SSL: "true"
penpot-mcp:
image: "penpotapp/mcp:${PENPOT_VERSION:-2.16}"
restart: always
networks:
- penpot
penpot-exporter:
image: "penpotapp/exporter:${PENPOT_VERSION:-2.16}"
restart: always
depends_on:
penpot-valkey:
condition: service_healthy
networks:
- penpot
environment:
<< : [*penpot-secret-key, *penpot-public-uri]
PENPOT_INTERNAL_URI: http://penpot-frontend:8080
PENPOT_REDIS_URI: redis://penpot-valkey/0
penpot-postgres:
image: "postgres:15"
restart: always
stop_signal: SIGINT
healthcheck:
test: ["CMD-SHELL", "pg_isready -U penpot"]
interval: 2s
timeout: 10s
retries: 5
start_period: 2s
volumes:
- penpot_postgres_v15:/var/lib/postgresql/data
networks:
- penpot
environment:
- POSTGRES_INITDB_ARGS=--data-checksums
- POSTGRES_DB=penpot
- POSTGRES_USER=penpot
- POSTGRES_PASSWORD=penpot
penpot-valkey:
image: valkey/valkey:8.1
restart: always
healthcheck:
test: ["CMD-SHELL", "valkey-cli ping | grep PONG"]
interval: 1s
timeout: 3s
retries: 5
start_period: 3s
networks:
- penpot
environment:
- VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu
And the caddyfile entry
penpot.mywebsite.com {
import crowdsec_bouncer
reverse_proxy penpot-frontend:8080
}
1 Like