Setting up the Penpot MCP Server with Docker and Traefik (Self-Hosted)

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:

PortPurpose
4400Plugin UI (Vite dev server) — serves manifest.json and plugin.js
4401MCP HTTP server — what Claude Code connects to
4402WebSocket 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 subshell
  • corepack enable alone — 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@latest

Hickup 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-host

Hickup 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.js

Hickup 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=4402

Final 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=4400

Connecting Claude Code

Add the MCP server globally:

claude mcp add penpot -t http https://mcp.example.com/mcp

Or edit ~/.claude.json directly under .mcpServers:

"penpot": { "type": "http", "url": "https://mcp.example.com/mcp" }

Installing the Plugin in Penpot

  1. Open any Penpot file
  2. Main menu → PluginsManage plugins
  3. Add: https://mcp.example.com/manifest.json
  4. Open the plugin panel → click Connect to MCP server

Once connected, Claude Code can create and manipulate shapes directly on the canvas.


Notes

  • The bootstrap script runs pnpm -r install && pnpm run build && pnpm run start on 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 so WS_URI gets baked into plugin.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-tokens feature 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

Similar Posts