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

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