# Penpot — VPS (ROOTLESS PODMAN). Ported from the home server 2026-08-12. # # First started 2026-09-21. Role + database created on the SHARED postgres, and the # same lines added to PostgreSQL/initdb/00-roles-and-databases.sql.template (note the # .template suffix -- the plain .sql path this header used to cite does not exist). # Managed by: systemctl --user enable --now podman-penpot.service # # Image tags are PINNED and watchtower is disabled on purpose. Penpot runs schema # migrations on boot and has shipped breaking major upgrades (1.x -> 2.x needed manual # steps), so it is a bad candidate for unattended updates. Bump the tags deliberately, # and back up the assets volume WITH the database -- a pg_dump alone will not restore. # # tty/stdin_open are false on every service here. A TTY makes tools in an entrypoint # think they are interactive; usesend hung silently on exactly that (2026-09-19). # # NOTE: the home version used postgres:15-alpine with --data-checksums. The shared # instance is postgres 17 without checksums. Penpot does not care, and since there is # no existing data this is not a migration — but if you ever DO need checksums, they # can only be set at initdb time for the whole cluster. # # Redis stays local to this stack: Penpot uses it for transient session and # notification state only, so it is not worth centralising. # # Rootless adaptations from the home version: # - Dropped its private postgres:15-alpine; uses the shared instance (AGENTS.md §2). # - PENPOT_DATABASE_URI repointed from penpot-db to the shared postgresql. # - :Z on the assets bind mount. It is shared between frontend and backend, so it # uses LOWERCASE :z -- uppercase would give each container a private MCS category # and the second to start would lock the first out (AGENTS.md §6). # - /etc/localtime mounts removed in favour of TZ (AGENTS.md §8). # # Public sign-up is off (disable-registration). Accounts come from Authentik SSO # instead: enable-oidc-registration creates a profile on first login. # # NPM: proxy penpot.gbrown.org -> http://penpot-frontend:8080 # # MCP (added 2026-09-23): `enable-mcp` on the FRONTEND makes its entrypoint write # /mcp/stream, /mcp/sse and /mcp/ws locations proxying to penpot-mcp:4401 (HTTP) and # :4402 (plugin websocket), so NPM needs no extra config. The flag is also on the # backend, matching upstream, where it gates per-user MCP keys (Account > Integrations). # Claude Code connects to https://penpot.gbrown.org/mcp/stream?userToken=. networks: nginx-bridge: external: true services: penpot-frontend: image: docker.io/penpotapp/frontend:2.17.2 container_name: penpot-frontend hostname: penpot-frontend domainname: penpot.gbrown.org networks: ['nginx-bridge'] environment: - TZ=America/New_York - PENPOT_FLAGS=disable-registration disable-login-with-password enable-login-with-oidc enable-oidc-registration enable-smtp enable-mcp labels: com.centurylinklabs.watchtower.enable: "false" volumes: - ./volumes/assets:/opt/data/assets:z depends_on: - penpot-backend - penpot-exporter - penpot-mcp tty: false stdin_open: false restart: unless-stopped penpot-backend: image: docker.io/penpotapp/backend:2.17.2 container_name: penpot-backend hostname: penpot-backend domainname: penpot.gbrown.org networks: ['nginx-bridge'] environment: - TZ=America/New_York # Heap deliberately UNCAPPED (Gib's call, 2026-09-21): the JVM takes ~25% of # host RAM, measuring ~2.3GB resident. Box has 16GB and the priority is # responsiveness, not footprint. If it gets tight, Penpot moves to the home # server rather than getting squeezed here. Set JVM_OPTS=-Xmx to cap. - PENPOT_FLAGS=disable-registration disable-login-with-password enable-login-with-oidc enable-oidc-registration enable-smtp enable-prepl-server enable-mcp - PENPOT_PUBLIC_URI=https://penpot.gbrown.org # ── SMTP via the UseSend stack on this same host ────────────────────── # The proxy ADVERTISES "AUTH LOGIN PLAIN" before STARTTLS but actually refuses # it ("538 Must issue a STARTTLS command first"), so TLS is mandatory. It is # reached by the smtp.usesend.gbrown.org network alias rather than its # container name so the wildcard cert's hostname check passes. # Username is the proxy's SMTP_AUTH_USERNAME; password is a UseSend API key. # Max message size is 10MB. - PENPOT_SMTP_HOST=smtp.usesend.gbrown.org - PENPOT_SMTP_PORT=587 - PENPOT_SMTP_USERNAME=usesend - PENPOT_SMTP_PASSWORD=${PENPOT_SMTP_PASSWORD:?set this in .env -- a UseSend API key} - PENPOT_SMTP_TLS=true - PENPOT_SMTP_SSL=false - PENPOT_SMTP_DEFAULT_FROM=Penpot - PENPOT_SMTP_DEFAULT_REPLY_TO=Penpot - PENPOT_SECRET_KEY=${PENPOT_SECRET_KEY} # Shared postgres, not a private container - PENPOT_DATABASE_URI=postgresql://postgresql/penpot - PENPOT_DATABASE_USERNAME=penpot - PENPOT_DATABASE_PASSWORD=${PENPOT_DATABASE_PASSWORD} # Penpot defaults to min-size=60 AND max-size=60 -- it eagerly opens 60 # connections and never releases them. On the SHARED postgres (max_connections # = 100) that silently ate 60% of the cluster's slots and other apps started # getting "remaining connection slots are reserved for SUPERUSER". Undocumented # in Penpot's config guide; the numbers are visible in its startup log line # `app.db - initialize connection pool ... min-size=60, max-size=60`. - PENPOT_DATABASE_MIN_POOL_SIZE=4 - PENPOT_DATABASE_MAX_POOL_SIZE=12 - PENPOT_REDIS_URI=redis://penpot-redis/0 - PENPOT_OBJECTS_STORAGE_BACKEND=fs - PENPOT_OBJECTS_STORAGE_FS_DIRECTORY=/opt/data/assets - PENPOT_TELEMETRY_ENABLED=false # ── SSO via Authentik (app slug "penpot", any Authentik user) ──────── # Login is SSO-only (disable-login-with-password). Existing profiles match # by email, and enable-oidc-registration creates a profile on first SSO # login, while disable-registration keeps the public sign-up form off. # Authority must keep its trailing slash; Penpot appends the discovery path. - PENPOT_OIDC_BASE_URI=https://auth.gbrown.org/application/o/penpot/ - PENPOT_OIDC_CLIENT_ID=${PENPOT_OIDC_CLIENT_ID:?set this in .env} - PENPOT_OIDC_CLIENT_SECRET=${PENPOT_OIDC_CLIENT_SECRET:?set this in .env} - PENPOT_OIDC_SCOPES=openid profile email labels: com.centurylinklabs.watchtower.enable: "false" volumes: - ./volumes/assets:/opt/data/assets:z depends_on: - penpot-redis tty: false stdin_open: false restart: unless-stopped penpot-mcp: # Upstream image defaults: `node index.js --multi-user`, listening on 0.0.0.0. # No volumes and no published ports; only the frontend nginx talks to it. image: docker.io/penpotapp/mcp:2.17.2 container_name: penpot-mcp hostname: penpot-mcp domainname: penpot.gbrown.org networks: ['nginx-bridge'] environment: - TZ=America/New_York labels: com.centurylinklabs.watchtower.enable: "false" tty: false stdin_open: false restart: unless-stopped penpot-exporter: image: docker.io/penpotapp/exporter:2.17.2 container_name: penpot-exporter hostname: penpot-exporter domainname: penpot.gbrown.org networks: ['nginx-bridge'] environment: - TZ=America/New_York - PENPOT_PUBLIC_URI=http://penpot-frontend:8080 # REQUIRED and missing from the original port: the exporter validates a # :secret-key at boot and crash-loops without it. Must match the backend. - PENPOT_SECRET_KEY=${PENPOT_SECRET_KEY} - PENPOT_REDIS_URI=redis://penpot-redis/0 labels: com.centurylinklabs.watchtower.enable: "false" depends_on: - penpot-redis tty: false stdin_open: false restart: unless-stopped penpot-redis: image: docker.io/library/redis:7-alpine container_name: penpot-redis hostname: penpot-redis domainname: penpot.gbrown.org networks: ['nginx-bridge'] environment: - TZ=America/New_York labels: com.centurylinklabs.watchtower.enable: "false" tty: false stdin_open: false restart: unless-stopped