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
}