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
920420and943120both cause issues with Penpot.920420blocks Penpot’sContent-Type: application/transit+jsonon all API calls (register, login, etc.), resulting in a 403 with no clear indication of the cause.943120triggers a false positive on Penpot’s WebSocket notifications endpoint (/ws/notifications?session-id=...), which legitimately lacks aRefererheader during the handshake, flagging it as a “Possible Session Fixation Attack.” I had to add scoped exceptions for both in my AppSeccustom-config.yamlconfig:
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-1whenPENPOT_OBJECTS_STORAGE_S3_REGIONisn’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.