A walkthrough of getting the official Penpot MCP server running as a Docker container behind a Traefik reverse proxy, so Claude Code (or any MCP client) can interact with a self-hosted Penpot instance.
Environment:
- Self-hosted Penpot 2.14 via Docker Compose
- Traefik v2 reverse proxy
- Domain: custom subdomain (
mcp.example.com) - MCP client: Claude Code CLI
Architecture
The @penpot/mcp package runs three services inside one container:
| Port | Purpose |
|---|---|
| 4400 | Plugin UI (Vite dev server) — serves manifest.json and plugin.js |
| 4401 | MCP HTTP server — what Claude Code connects to |
| 4402 | WebSocket server — what the Penpot browser plugin connects to |
The browser plugin (loaded inside Penpot) connects via WebSocket to port 4402. Claude Code connects via HTTP to port 4401. The manifest and plugin JS are served from port 4400.
Hickup 1: sh: pnpm: not found
Problem: The penpot-mcp binary runs corepack pnpm run bootstrap on every startup. The bootstrap script spawns a subshell running pnpm -r install && pnpm run build && pnpm run start. Even after installing pnpm, the subshell couldn’t find it.
Attempts that failed:
npm install -g pnpm— pnpm installed but not visible in bootstrap subshellcorepack enablealone — creates a shim but doesn’t download the actual pnpm binary
Fix: Use corepack prepare pnpm@latest --activate — this actually downloads and activates the pnpm binary, not just a shim. Also switch from node:22-alpine (busybox ash) to node:22-slim (Debian) for more predictable shell PATH behavior.
FROM node:22-slim
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@latest --activate && npm install -g @penpot/mcp@latestHickup 2: Vite blocks the custom domain
Problem: The plugin UI (port 4400) is a Vite dev server. Vite 6+ blocks requests with a Host header that isn’t localhost. When Traefik forwarded https://mcp.example.com/manifest.json to the container, Vite returned:
Blocked request. This host ("mcp.example.com") is not allowed.
To allow this host, add "mcp.example.com" to `preview.allowedHosts` in vite.config.js.Fix: Add a Traefik middleware that rewrites the Host header to localhost:4400 before the request reaches Vite. Vite always allows localhost.
- traefik.http.middlewares.penpot-mcp-host.headers.customrequestheaders.Host=localhost:4400
- traefik.http.routers.penpot-mcp-plugin.middlewares=penpot-mcp-hostHickup 3: Plugin manifest URL rejected by Penpot
Problem: Penpot plugin manager said “The plugin doesn’t exist or the URL is not correct” when entering the manifest URL.
Root cause 1: Using http:// URL from an HTTPS Penpot instance. Browsers block mixed content — Penpot’s frontend JS cannot fetch an HTTP resource from an HTTPS page. Always use https:// for the manifest URL.
Root cause 2: The WebSocket URL (ws://localhost:4402) is burned into plugin.js at Vite build time via the WS_URI environment variable. Without setting this, the plugin tries to connect to ws://localhost:4402 — which fails as mixed content from an HTTPS origin.
Fix: Set WS_URI in the container environment so Vite bakes the correct WSS URL into plugin.js during the startup build:
environment:
WS_URI: "wss://mcp.example.com/ws"Hickup 4: Wrong environment variable names
Problem: The @penpot/mcp README documents several environment variables (PENPOT_MCP_SERVER_ADDRESS, MCP_PORT, PLUGIN_PORT, etc.) that are never actually read by the source code. Setting them has no effect.
Variables that actually work:
PENPOT_MCP_SERVER_HOST: "0.0.0.0" # bind address for MCP HTTP server
PENPOT_MCP_SERVER_PORT: "4401" # MCP HTTP port
PENPOT_MCP_WEBSOCKET_PORT: "4402" # WebSocket port
PENPOT_MCP_PLUGIN_SERVER_HOST: "0.0.0.0" # bind address for Vite plugin server
PENPOT_MCP_REMOTE_MODE: "true" # disables local filesystem tool
WS_URI: "wss://mcp.example.com/ws" # WebSocket URL baked into plugin.jsHickup 5: WebSocket needs its own Traefik route
Problem: Port 4402 (WebSocket) must be reachable as wss:// from the browser (mixed content rules apply). Simply exposing port 4402 on the host doesn’t help — the browser can’t use plain ws:// from an HTTPS page.
Fix: Add a dedicated Traefik router for /ws that proxies to port 4402. Traefik handles WebSocket upgrades natively — no special configuration needed beyond routing the path.
- traefik.http.middlewares.penpot-mcp-ws-strip.stripprefix.prefixes=/ws
- traefik.http.routers.penpot-mcp-ws.rule=Host(`mcp.example.com`) && PathPrefix(`/ws`)
- traefik.http.routers.penpot-mcp-ws.service=penpot-mcp-ws-svc
- traefik.http.routers.penpot-mcp-ws.middlewares=penpot-mcp-ws-strip
- traefik.http.routers.penpot-mcp-ws.priority=30
- traefik.http.services.penpot-mcp-ws-svc.loadbalancer.server.port=4402Final Working Docker Compose Service
Replace mcp.example.com with your actual domain.
penpot-mcp:
build:
context: .
dockerfile_inline: |
FROM node:22-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates git python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN npm install -g @penpot/mcp@latest
WORKDIR /usr/local/lib/node_modules/@penpot/mcp
RUN corepack pnpm -r install
CMD ["corepack", "pnpm", "run", "bootstrap"]
container_name: penpot-mcp
environment:
PENPOT_MCP_SERVER_HOST: "0.0.0.0"
PENPOT_MCP_SERVER_PORT: "4401"
PENPOT_MCP_WEBSOCKET_PORT: "4402"
PENPOT_MCP_REMOTE_MODE: "true"
PENPOT_MCP_PLUGIN_SERVER_HOST: "0.0.0.0"
WS_URI: "wss://mcp.example.com/ws"
networks:
- proxy-traefik-network
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.docker.network=proxy-traefik-network
# Router 1: MCP HTTP/SSE API (priority 20)
- traefik.http.routers.penpot-mcp-api.rule=Host(`mcp.example.com`) && (PathPrefix(`/mcp`) || PathPrefix(`/sse`) || PathPrefix(`/messages`))
- traefik.http.routers.penpot-mcp-api.entrypoints=websecure
- traefik.http.routers.penpot-mcp-api.tls=true
- traefik.http.routers.penpot-mcp-api.tls.certresolver=myresolver
- traefik.http.routers.penpot-mcp-api.service=penpot-mcp-api-svc
- traefik.http.routers.penpot-mcp-api.priority=20
- traefik.http.services.penpot-mcp-api-svc.loadbalancer.server.port=4401
# Router 2: WebSocket bridge — /ws → port 4402 (priority 30)
- traefik.http.middlewares.penpot-mcp-ws-strip.stripprefix.prefixes=/ws
- traefik.http.routers.penpot-mcp-ws.rule=Host(`mcp.example.com`) && PathPrefix(`/ws`)
- traefik.http.routers.penpot-mcp-ws.entrypoints=websecure
- traefik.http.routers.penpot-mcp-ws.tls=true
- traefik.http.routers.penpot-mcp-ws.tls.certresolver=myresolver
- traefik.http.routers.penpot-mcp-ws.service=penpot-mcp-ws-svc
- traefik.http.routers.penpot-mcp-ws.middlewares=penpot-mcp-ws-strip
- traefik.http.routers.penpot-mcp-ws.priority=30
- traefik.http.services.penpot-mcp-ws-svc.loadbalancer.server.port=4402
# Router 3: Plugin UI / manifest.json / plugin.js catch-all (priority 10)
# Rewrite Host header so Vite accepts the request
- traefik.http.middlewares.penpot-mcp-host.headers.customrequestheaders.Host=localhost:4400
- traefik.http.routers.penpot-mcp-plugin.rule=Host(`mcp.example.com`)
- traefik.http.routers.penpot-mcp-plugin.entrypoints=websecure
- traefik.http.routers.penpot-mcp-plugin.tls=true
- traefik.http.routers.penpot-mcp-plugin.tls.certresolver=myresolver
- traefik.http.routers.penpot-mcp-plugin.middlewares=penpot-mcp-host
- traefik.http.routers.penpot-mcp-plugin.service=penpot-mcp-plugin-svc
- traefik.http.routers.penpot-mcp-plugin.priority=10
- traefik.http.services.penpot-mcp-plugin-svc.loadbalancer.server.port=4400Connecting Claude Code
Add the MCP server globally:
claude mcp add penpot -t http https://mcp.example.com/mcpOr edit ~/.claude.json directly under .mcpServers:
"penpot": { "type": "http", "url": "https://mcp.example.com/mcp" }Installing the Plugin in Penpot
- Open any Penpot file
- Main menu → Plugins → Manage plugins
- Add:
https://mcp.example.com/manifest.json - Open the plugin panel → click Connect to MCP server
Once connected, Claude Code can create and manipulate shapes directly on the canvas.
Notes
- The
bootstrapscript runspnpm -r install && pnpm run build && pnpm run starton every container start. Pre-installing deps in the image (RUN corepack pnpm -r install) cuts startup time significantly — the build step still runs at startup soWS_URIgets baked intoplugin.js. - Build time for the Docker image is ~5–10 minutes on first run (apt + npm + pnpm deps).
- An alternative, simpler MCP server using the Penpot REST API (no browser plugin required):
zcube/penpot-mcp-server. Limitations vs@penpot/mcp:- Uses the Penpot REST API — no direct canvas manipulation (can’t create/move shapes in real time like the plugin API can)
- Self-hosted Penpot requires the
enable-access-tokensfeature flag set in your Penpot config before an access token can be generated - Requires generating a personal access token in Penpot Settings and passing it as
PENPOT_ACCESS_TOKEN - No live design interaction — operations go through HTTP round-trips to the Penpot backend, not directly into the open design file

