[Solved] Public-uri stuck on localhost:9001 behind Caddy

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:

  1. Hardcoding PENPOT_PUBLIC_URI: directly under the environment variables of penpot-frontend (bypassing YAML anchors).

  2. Running docker compose down && docker volume rm <assets_volume> && docker compose up -d to clear potential configuration caches in the frontend assets.

  3. 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:

  1. 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()
  1. 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