Building on my previous homelab post , this post walks through deploying Penpot in Kubernetes and connecting it to Claude Code through penpot-mcp . The result is a self-hosted design workflow where Claude can manipulate the Penpot canvas directly through the Plugin API — creating shapes, styling elements, and building layouts without manual UI work.

I use this setup to redesign MergeDocs , a fully client-side PDF merging utility, entirely from the terminal.


Motivation

Most AI-assisted design workflows involve uploading screenshots or design files to a third-party service. That may be fine for public projects, but it becomes much less appealing when the work is internal, proprietary, or simply not meant to leave your network.

Self-hosting Penpot solves the data residency problem: assets, files, and design history stay inside your own cluster. Connecting it to Claude Code through penpot-mcp adds another benefit: the AI can manipulate the canvas directly through the Plugin API instead of generating code that you have to copy, paste, and run yourself.

That collapses the feedback loop from several manual steps into a single prompt.

The practical result is a private, AI-augmented design environment that:

  • Keeps data local — no designs or screenshots leave the cluster
  • Works from the terminal — Claude Code can issue design commands without opening the Penpot UI
  • Persists across sessions — designs live in your self-hosted Penpot project, not in chat history
  • Scales with your cluster — storage, compute, and access control remain fully under your control

Architecture

Claude Code  (VS Code)
    │
    │  HTTPS  (mcp.penpot.local/mcp)
    ▼
Caddy (K8s ingress)
    │  HTTP → port 4401 (MCP server)
    │  WSS  → port 4402 (WebSocket)
    ▼
penpot-mcp pod  (penpot-mcp namespace)
    │
    │  WebSocket  (wss://mcp.penpot.local)
    ▼
Penpot Plugin (browser)   ◄── loaded from https://plugin.penpot.local/manifest.json
    │
    │  Plugin API
    ▼
https://penpot.local  (your canvas)

The browser plugin acts as the bridge between Claude Code and the Penpot canvas. All design operations — creating shapes, adding text, creating frames, and applying styles — are executed as JavaScript inside the plugin context running in the browser.


Deployment

1. Caddy ingress

Three TLS certificates are needed — one for each subdomain. Run the following commands to generate them with mkcert, then load them into the cluster as Kubernetes secrets:

# Generate TLS certs in current directory
mkcert -cert-file penpot-tls.pem -key-file penpot-tls-key.pem penpot.local
mkcert -cert-file penpot-mcp-tls.pem -key-file penpot-mcp-tls-key.pem mcp.penpot.local
mkcert -cert-file penpot-plugin-tls.pem -key-file penpot-plugin-tls-key.pem plugin.penpot.local

# Load certs into cluster
kubectl create secret tls penpot-tls ^
  --cert=penpot-tls.pem ^
  --key=penpot-tls-key.pem ^
  --namespace=caddy ^
  --dry-run=client -o yaml | kubectl apply -f -

kubectl create secret tls penpot-mcp-tls ^
  --cert=penpot-mcp-tls.pem ^
  --key=penpot-mcp-tls-key.pem ^
  --namespace=caddy ^
  --dry-run=client -o yaml | kubectl apply -f -

kubectl create secret tls penpot-plugin-tls ^
  --cert=penpot-plugin-tls.pem ^
  --key=penpot-plugin-tls-key.pem ^
  --namespace=caddy ^
  --dry-run=client -o yaml | kubectl apply -f -

Each mkcert command generates a locally trusted certificate, and each kubectl pipeline loads it into the caddy namespace as a TLS secret.

Then append the following to your existing all-in-one.yaml to mount the certificates and add the required virtual host blocks:

all-in-one.yaml — Caddy
apiVersion: v1
kind: ConfigMap
metadata:
  name: caddy-config
  namespace: caddy
data:
  # Append these to existing Caddyfile
  Caddyfile: |
    penpot.local {
      tls /certs/penpot/tls.crt /certs/penpot/tls.key
      reverse_proxy penpot.penpot.svc.cluster.local:8080
    }
    mcp.penpot.local {
      tls /certs/penpot-mcp/tls.crt /certs/penpot-mcp/tls.key
      @websocket {
        header Connection *Upgrade*
        header Upgrade websocket
      }
      reverse_proxy @websocket penpot-mcp.penpot-mcp.svc.cluster.local:4402
      reverse_proxy penpot-mcp.penpot-mcp.svc.cluster.local:4401
    }
    plugin.penpot.local {
      tls /certs/penpot-plugin/tls.crt /certs/penpot-plugin/tls.key
      reverse_proxy penpot-mcp.penpot-mcp.svc.cluster.local:4400 {
        header_up Host "localhost"
      }
    }
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: caddy
  namespace: caddy
spec:
  replicas: 1
  selector:
    matchLabels:
      app: caddy
  template:
    metadata:
      labels:
        app: caddy
    spec:
      containers:
      - name: caddy
        image: caddy:2-alpine
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 443
        - containerPort: 80
        volumeMounts:
        # Existing volumeMounts
        - name: certs-penpot
          mountPath: /certs/penpot
          readOnly: true
        - name: certs-penpot-mcp
          mountPath: /certs/penpot-mcp
          readOnly: true
        - name: certs-penpot-plugin
          mountPath: /certs/penpot-plugin
          readOnly: true
      volumes:
      # Existing volumes
      - name: certs-penpot
        secret:
          secretName: penpot-tls
      - name: certs-penpot-mcp
        secret:
          secretName: penpot-mcp-tls
      - name: certs-penpot-plugin
        secret:
          secretName: penpot-plugin-tls

Add all three hostnames to your Windows hosts file, pointing them to the Caddy LoadBalancer IP:

192.168.68.220    penpot.local mcp.penpot.local plugin.penpot.local

Caddy routes traffic as follows:

  • https://penpot.local → Penpot frontend on port 8080
  • https://mcp.penpot.local → MCP HTTP server on port 4401, with WebSocket upgrades routed to port 4402
  • https://plugin.penpot.local → Vite plugin server on port 4400, with Host: localhost forwarded to satisfy Vite host checks

2. Penpot

Penpot is deployed with Helm using a custom values.yaml. In this setup, SMTP is not configured, so email verification is disabled and open registration is enabled for initial setup.

This is the custom values.yaml:

values.yaml — Penpot
config:
  # Public URL — must match your ingress host
  publicUri: "https://penpot.local"

  # Feature flags
  # disable-email-verification: skip email confirm step (no SMTP in homelab)
  # After creating your admin account add "disable-registration" to close sign-ups
  flags: "enable-registration enable-login-with-password disable-email-verification"

# ---------------------------------------------------------------------------
# Bundled dependencies
# ---------------------------------------------------------------------------
global:
  postgresqlEnabled: true
  valkeyEnabled: true

# ---------------------------------------------------------------------------
# Asset persistence (uploaded files, images, fonts)
# ---------------------------------------------------------------------------
persistence:
  assets:
    enabled: true
    storageClass: "nfs"
    accessModes:
      - ReadWriteMany
    size: 5Gi

# ---------------------------------------------------------------------------
# Bundled PostgreSQL
# ---------------------------------------------------------------------------
postgresql:
  auth:
    username: penpot
    password: penpot
    database: penpot
  primary:
    persistence:
      enabled: true
      storageClass: "nfs"
      accessModes:
        - ReadWriteMany
      size: 2Gi
    initContainers:
      - name: fix-permissions
        image: busybox
        command: ["sh", "-c", "chown -R 999:999 /bitnami/postgresql"]
        volumeMounts:
          - name: data
            mountPath: /bitnami/postgresql

# ---------------------------------------------------------------------------
# Bundled Valkey (Redis-compatible)
# ---------------------------------------------------------------------------
valkey:
  primary:
    persistence:
      enabled: true
      storageClass: "nfs"
      accessModes:
        - ReadWriteMany
      size: 1Gi

# ---------------------------------------------------------------------------
# Ingress — disabled, Caddy handles TLS termination and reverse proxy
# ---------------------------------------------------------------------------
ingress:
  enabled: false

Install Penpot with:

helm repo add penpot https://helm.penpot.app
helm repo update
kubectl create ns penpot
helm install penpot penpot/penpot --namespace penpot -f values.yaml

Then open https://penpot.local in your browser and register an account.

penpot-dashboard

3. Penpot MCP

penpot-mcp is Penpot’s MCP server. It exposes a persistent HTTP server on port 4401 and a WebSocket server on port 4402, which the browser plugin connects to. Claude Code communicates with the MCP server over HTTP, while the plugin relays commands into the live Penpot canvas.

Since this cluster runs on Talos — an immutable OS with no SSH access and no direct image import workflow — I build the image locally, push it to Docker Hub, and let Kubernetes pull it at deploy time.

3.1. Build and push the image

Save the following Dockerfile in your working directory, which should be the parent directory of the penpot-repo/ clone. The line COPY penpot-repo/mcp/ . assumes that penpot-repo/ exists in the build context root, so both the Git clone and the docker build command must be run from the same parent directory.

Dockerfile — Penpot MCP
FROM node:22-alpine

# Required for the 'sharp' native module (image processing)
RUN apk add --no-cache python3 make g++ vips-dev

# Match the packageManager specified in package.json
RUN npm install -g pnpm@10.28.2

WORKDIR /app

# Copy the penpot mcp workspace
COPY penpot-repo/mcp/ .

# Install all workspace dependencies at image build time
RUN pnpm -r install

# Default ports and WebSocket URL (can be overridden by K8s env vars)
ENV PENPOT_MCP_SERVER_PORT=4401
ENV PENPOT_MCP_WEBSOCKET_PORT=4402
ENV PENPOT_MCP_REPL_PORT=4403
ENV WS_URI=wss://mcp.penpot.local

EXPOSE 4400 4401 4402

# At container start: build plugin (bakes WS_URI into JS), then start all services
CMD ["sh", "-c", "pnpm run build && pnpm run start"]

Make sure you are logged in to Docker Hub first with docker login, then run:

# Sparse-clone only the mcp/ directory from the mcp-prod branch
git clone --branch mcp-prod --depth 1 --filter=blob:none --sparse https://github.com/penpot/penpot.git penpot-repo
cd penpot-repo
git sparse-checkout set mcp
cd ..

# Build from the parent directory so COPY penpot-repo/mcp/ resolves correctly
docker build -t seehiong/penpot-mcp:latest .
docker push seehiong/penpot-mcp:latest

3.2. Deploy to Kubernetes

This is my all-in-one.yaml:

all-in-one.yaml — Penpot MCP
apiVersion: v1
kind: Namespace
metadata:
  name: penpot-mcp
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: penpot-mcp
  namespace: penpot-mcp
spec:
  replicas: 1
  selector:
    matchLabels:
      app: penpot-mcp
  template:
    metadata:
      labels:
        app: penpot-mcp
    spec:
      containers:
      - name: penpot-mcp
        image: seehiong/penpot-mcp:latest
        imagePullPolicy: Always
        ports:
        - containerPort: 4400
          name: plugin
        - containerPort: 4401
          name: mcp-http
        - containerPort: 4402
          name: websocket
        env:
        - name: WS_URI
          value: "wss://mcp.penpot.local"
        - name: PENPOT_MCP_SERVER_PORT
          value: "4401"
        - name: PENPOT_MCP_WEBSOCKET_PORT
          value: "4402"
        resources:
          requests:
            memory: "256Mi"
            cpu: "100m"
          limits:
            memory: "1Gi"
            cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
  name: penpot-mcp
  namespace: penpot-mcp
spec:
  selector:
    app: penpot-mcp
  ports:
  - name: plugin
    port: 4400
    targetPort: 4400
  - name: mcp-http
    port: 4401
    targetPort: 4401
  - name: websocket
    port: 4402
    targetPort: 4402

Apply the manifest and wait for the rollout to complete:

kubectl apply -f all-in-one.yaml
kubectl rollout status deployment/penpot-mcp -n penpot-mcp

3.3. Wire Claude Code

Add the following entry to your project’s .mcp.json:

"penpot-mcp": {
  "type": "http",
  "url": "https://mcp.penpot.local/mcp"
}

3.4. Connect the browser plugin

  1. Open https://penpot.local and open any design file
  2. Click the puzzle piece icon in the top toolbar
  3. Choose Add plugin and enter: https://plugin.penpot.local/manifest.json
  4. Open the plugin panel and click “Connect to MCP server”

The plugin must remain open and connected while Claude Code is issuing design commands.

penpot-plugin-connected

3.5. Verify with MCP Inspector

To confirm that the MCP endpoint is reachable and the tools are registered:

npx @modelcontextprotocol/inspector

In the Inspector UI, use:

  • Transport: Streamable HTTP
  • URL: https://mcp.penpot.local/mcp
  • Mode: Via Proxy

You should see five registered tools: execute_code, high_level_overview, penpot_api_info, export_shape, import_image.

penpot-mcp-inspector

If the browser plugin is not connected first, tool calls will typically return a timeout error.


First Design test

This post is written during Hari Raya Aidilfitri, so instead of a plain Hello World, I used the setup to create a festive greeting card first. Selamat Hari Raya to all Muslims celebrating!

With the plugin connected, I gave Claude the following prompt:

“Create a Selamat Hari Raya greeting card in Penpot — warm festive colours, a crescent and star motif, the greeting in large text, and a short blessing beneath it.”

Claude called execute_code through penpot-mcp, and the greeting card appeared directly on the canvas — confirming that the full pipeline was working end to end.

penpot-selamat-hari-raya

MergeDocs Redesign

MergeDocs is a fully client-side PDF merging utility: no server, no uploads, and all processing happens in the browser.

Current design

penpot-pdf-merge-current-design

The existing UI is functional, but visually minimal. Using penpot-mcp, I asked Claude Code to produce an updated wireframe with a more modern, card-based layout and clearer action hierarchy.

Updated design via Claude Code

penpot-pdf-merge-updated-design

Claude generated the wireframe by calling execute_code through the Penpot Plugin API — creating boards, text layers, rectangles, and styles entirely from the terminal, without using the Penpot UI directly.


Conclusion

Self-hosting Penpot in Kubernetes turns your homelab into a private, persistent design environment. Adding Claude Code through penpot-mcp makes it even more useful: AI can move from suggesting design ideas to applying them directly on the canvas.

That makes the workflow especially compelling for internal tools, sensitive projects, and fast UI prototyping. Once the bridge is in place, the pattern is simple: export the current design, describe the changes, and let Claude generate the Penpot Plugin API code to update the canvas for you.