2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

逸般家庭のProxmoxにPenpotを立てて、Claude Codeにデザインを見ながらアプリを作ってもらう

2
Posted at

この記事の目標

逸般家庭のProxmoxにPenpotを立てて、Claude Codeにデザインを見ながらiPhoneアプリを作ってもらう

きっかけ

iPhoneアプリを作るにあたって、画面のモックをちゃんと管理したくなった。

せっかく家にProxmoxがあるので、オープンソースのデザインツール「Penpot」をセルフホストすることにしました。
しかもPenpotには公式のMCPサーバーがあるので、Claude Codeから直接デザインを触れるっぽい。

これは試すしかない

目指したいもの

最終的にはこんな流れにしたい。

  1. 作りたいものをGitHubのIssueに書く
  2. Claude CodeがPenpotでモックを作る
  3. Claude Codeが実装しつつ、モックとの差分をチェックする
  4. XcodeCloudでビルドして、TestFlightで実機テスト
  5. 事実上スマホだけで開発が完結!

今のところ2〜4はできていて、1と5はこれから。

前回の記事ではMacなしでiOS開発する夢を見ましたが、今回はMacすら触らない(使わないとは言っていない)という方針です。ちゃんと合法。

つかうもの(つかったもの)

役割 つかったもの
仮想化 Proxmox VE 9.2(2ノードのクラスタ)
アプリ Penpot 2.18(Docker Compose)
リバースプロキシと証明書 Caddy + Let's Encrypt(DNS-01でワイルドカード証明書)
証明書用のDNS Azure DNS(サブドメインだけ委任)
家の中の名前解決 AdGuard Home
ログイン Entra ID(OIDC)
アクセス制限 Proxmox Firewall
開発 Claude Code、XcodeCloud、TestFlight

構成はこんな感じ。

自宅のネットワーク(VLANで分けている)
 ├ 作業用のMac(Claude Code、ブラウザ)
 └ Proxmox(2ノードのクラスタ)
     ├ AdGuard Home(家のDNS)
     └ PenpotのVM(Debian 12)
         ├ Caddy(443番、証明書を自動で取得)
         └ Penpot(frontend / backend / exporter / mcp / postgres / valkey)

お名前.comのDNS(example.jp)── internal.example.jp を委任 ──▶ Azure DNS
                                                      └ 証明書の取得用のTXTレコード

※ドメイン名は example.jp に置き換えてます

やってみよう

家の中だけで使うけどHTTPSにする

PenpotはHTTPでも動くんですが、コピペなど一部の機能はブラウザの仕様でHTTPSが必要らしい。
そのため、家の中だけで使うサーバーでも、ちゃんとした証明書を付けることにしました。

ただ、家のサーバーはインターネットから届かないので、Let's EncryptのHTTP-01チャレンジは使えない。
そこでDNS-01チャレンジを使います。DNSにTXTレコードを書ければ、外から届かなくても証明書が取れるやつです。

サブドメインだけAzure DNSに委任する

example.jp のDNSはお名前.comで管理していて、APIからTXTレコードをいじれません。

なので、internal.example.jp だけをAzure DNSにNSで委任しました。
メールなどのレコードはお名前.comに置いたままなので、既存の環境には影響なし。

CaddyにAzure DNSのプラグインを入れる

caddy-dns/azure を入れてビルドします。

FROM caddy:2.10-builder AS builder
RUN xcaddy build --with github.com/caddy-dns/azure

FROM caddy:2.10
COPY --from=builder /usr/bin/caddy /usr/bin/caddy

CaddyがAzureに接続するためのサービスプリンシパルには、TXTレコードの読み書きだけを許すカスタムロールを割り当てておきます。
これでシークレットが漏れても、Aレコードを書き換えられることはありません。

Caddyfileはこんな感じ。

*.internal.example.jp {
	tls {
		dns azure {
			tenant_id {env.AZURE_TENANT_ID}
			client_id {env.AZURE_CLIENT_ID}
			client_secret {env.AZURE_CLIENT_SECRET}
			subscription_id {env.AZURE_SUBSCRIPTION_ID}
			resource_group_name {env.AZURE_RESOURCE_GROUP_NAME}
		}
		# TXT の反映確認は公開リゾルバで行う(家の DNS の影響を避ける)
		resolvers 1.1.1.1 8.8.8.8
	}

	@penpot host penpot.internal.example.jp
	handle @penpot {
		reverse_proxy penpot-frontend:8080
	}

	handle {
		abort
	}
}

最初はステージング環境で試して、約15秒で証明書が取れたのを確認してから本番に切り替えました。

家の中の名前解決は、AdGuard HomeのDNS書き換えで penpot.internal.example.jp をVMのIPアドレスに向けてます。
外のDNSにはAレコードを置いていないので、家の外からは名前解決すらできません。
(外から使う話は「これから」で書きます)

公式のdocker-compose.yamlは編集しない

公式の docker-compose.yaml はそのまま置いて、変更はぜんぶ compose.local.yaml に記載。
.env にこう書いておけば、docker compose up -d だけで2つのファイルが合わさります。

COMPOSE_FILE=docker-compose.yaml:compose.local.yaml

アップデートのときは公式のファイルを取り直すだけでOK。楽ちん。

公式のファイルにある設定を消したいときは、Composeの !reset と !override が使えます。
たとえば、frontendのポート公開をやめてCaddyからしか届かないようにするならこう。

services:
  penpot-frontend:
    ports: !reset []
    depends_on: !override
      - penpot-backend
      - penpot-exporter
      - penpot-mcp

ほかに変えたのはこのあたり。

  • 秘密鍵とDBのパスワードを .env のランダムな値にした(公式の初期値は penpot)
  • 秘密鍵はfrontendに渡さない(ブラウザ向けの設定に書き出されるおそれがあるため)
  • 管理コンソールとテスト用のメールサーバー(Mailpit)は profiles を付けて起動しない
  • 登録できるメールアドレスを自分のドメインに限定(PENPOT_REGISTRATION_DOMAIN_WHITELIST)

ログインはEntra IDだけにする

自前のドメインでMicrosoft 365を使っているので、ログインもEntra IDのOIDCにしました。
Entra ID側で「割り当てが必要」を有効にして、決めたグループのユーザーだけがログインできるようにしてます。

リダイレクトURIでハマった

古い資料だとリダイレクトURIが /api/auth/oauth/oidc/callback になってるんですが、Penpot 2.xでは /api/auth/oidc/callback でした。

古いほうを登録すると、Entra IDの画面で AADSTS50011 のエラーになります。

OIDCでログインできるのを確認したら、パスワードでのログインは無効にしました(disable-login-with-password)。
Entra IDに障害があったとき用に、ローカルの管理者アカウントを1つ作ってあって、フラグを一時的に戻せば使えます。

MCPサーバーを有効にする

Penpotには公式のMCPサーバー(penpot-mcp)があります。
公式の docker-compose.yaml にも入ってるんですが、初期状態では起動しません。

PENPOT_FLAGS に enable-mcp を追加して、penpot-mcp を起動するようにします。

x-flags: &flags
  PENPOT_FLAGS: >-
    disable-email-verification
    enable-registration
    enable-login-with-oidc
    disable-login-with-password
    enable-prepl-server
    enable-mcp

フラグがあると、frontendのnginxに /mcp/stream と /mcp/ws のルートが作られます。

仕組みがちょっと変わってる

MCPサーバー自身はファイルを編集しません。
Claude Codeからの操作をブラウザで開いているPenpotに中継して、ブラウザ側がそのユーザーの権限で実行する、という仕組みです。

[Claude Code] ──/mcp/stream?userToken=…──▶ [penpot-mcp] ◀──/mcp/ws?userToken=…── [ブラウザのPenpot]

なので、Claude Codeから操作するときは、ブラウザでPenpotのファイルを開いて、MCPのプラグインで接続しておく必要があります。
userToken はユーザーごとに発行されるので、複数人でもそれぞれのアカウントで使えます。

Claude Codeに登録する

Penpotのアカウント設定でMCPを有効にすると、トークン付きのURLが表示されるので、それを登録します。

claude mcp add --transport http --scope user penpot "https://penpot.internal.example.jp/mcp/stream?userToken=<トークン>"

※このトークンはパスワードと同じ扱いです。知ってれば誰でもそのユーザーとして操作できちゃうので、リポジトリの .mcp.json には書かずに --scope user で登録しましょう。

CPUの種類がkvm64だとMCPサーバーが起動しない

MCPを有効にしたら、penpot-mcp のコンテナが再起動を繰り返す・・・

ログを見るとこんなエラー。

Unsupported CPU: Prebuilt binaries for Linux x64 require v2 microarchitecture

penpot-mcp は画像処理にsharpを使っていて、sharpのビルド済みバイナリはx86-64-v2の命令セットが必要とのこと。
ProxmoxでVMを作るときにCPUの種類を指定しないとkvm64になって、この命令セットがVMから見えません。

CPUの種類を x86-64-v2-AES に変えて、VMを停止→起動し直したら無事起動しました。

qm set <VMID> --cpu x86-64-v2-AES

Penpot本体はkvm64でも普通に動いてたので、MCPを有効にするまで気づかなかった。
今はVMを作るコマンドに最初から --cpu x86-64-v2-AES を入れてます。

アプリ開発で使ってみた

作りかけのアプリをPenpotに取り込む

1つ目は、気分を記録するiPhoneとApple Watchのアプリ「エモーショントラッカー」。App Storeで配信中です。

すでにSwiftUIで画面ができていたので、シミュレーターのスクショを見せて、Penpotに編集できる形で再現してもらいました。

Claude Codeがやってくれたのはこんな感じ。

  • Assets.xcassets の色を、ライトとダークの2組のデザイントークンにする
  • ボタンやタブバーなどの部品をコンポーネントにする
  • iPhoneの8画面、Watchの画面、コンプリケーションを組み立てる

コンポーネントの名前はSwiftUIの型名にそろえてあるので、コードとの対応も分かりやすい。

アプリからリバースエンジニアリングした画面モック

文字は最初Noto Sans JPで代用してたんですが、SF ProとSF CompactをPenpotにアップロードして、英数字の部分だけ差し替えてもらいました。
iPhoneも英数字はSF、日本語はヒラギノで表示してるので、それに近い組み合わせです。

新しいアプリはモックから作る

2つ目は、開発中(錯綜段階)のアプリ。こっちはまだTestFlightで試している段階で、App Storeには出していません。

コードを書く前に、Penpotでホーム画面、ウィジェット(小・中・大)、場所の登録画面のモックを作ってもらいました。
「もうちょっとパステル調に」「ダークモード版も作って」と頼むだけで、トークンの色を変えて全画面に反映してくれます。

imakore-light-dark.png

モックができてから実装へ。
Claude Codeにモックと実装の差分を一覧にしてもらうと、ウィジェットの背景色、中・大ウィジェットの縦の位置、アプリ未登録のときのボタンなどが出てきたので、まとめて直すPRを作ってもらいました。

画面を変えるときのルール

リポジトリでは、画面を変えるときのルールをこう決めてます。

  • 画面を変えるときは、先にPenpotを直す
  • Penpotにそろうようにコードを直す
  • PRには変更前後のスクショ(ライトとダーク、Penpotのボード)を載せる

gh コマンドではPRに画像を上げられないので、スクショはClaude Codeが撮ってファイルの場所を教えてくれて、PRには自分で貼ってます。

PenpotのMCPのクセ

使っていて気づいたことをまとめておきます。

起きたこと 対処
ブラウザのPenpotのタブがスリープすると、Claude Codeから操作できない タブを開き直して、プラグインで接続し直す
トークンの値を変えても、図形の色が自動で変わらない トークンを図形に付け直してもらう
チームにアップロードしたフォントが、プラグインのフォント一覧に出てこない Penpotの画面で1つの文字にそのフォントを設定して、その文字からフォントのIDを読み取ってもらう
日本語とSFが混ざった文字の幅を狭く計算して、変なところで改行される 文字の枠の幅を固定する

XcodeCloudとTestFlightで実機テスト

ビルドと配布はXcodeCloudに任せてます。

.xcodeproj はXcodeGenで作っていてコミットしていないので、clone直後に作るスクリプトを置きました。

ios/ci_scripts/ci_post_clone.sh
#!/bin/sh
# Xcode Cloud: リポジトリを clone した直後に実行される。
# .xcodeproj はコミットしていない(XcodeGen の生成物)ので、ここで作る。
set -eu

brew install xcodegen
cd "$CI_PRIMARY_REPOSITORY_PATH/ios"
xcodegen generate

ワークフローは、main ブランチの ios フォルダが変わったときだけビルドする設定。
ドキュメントやClaude Codeの設定だけを変えたPRではビルドしません。

ビルドが成功するとTestFlightの内部テストに配られるので、PRをマージしたら、しばらくしてiPhoneのTestFlightアプリから新しいビルドを入れられます。

これから

残っているのは、最初に書いた流れの1と5。

作りたいものは、スマホのGitHubアプリからIssueに書くイメージです。
Claude CodeがそのIssueを読んで、Penpotのモック、実装、PRまで進めて、自分はスマホでPRとスクショを見てマージする。
マージすればXcodeCloudがビルドしてTestFlightに配るので、そのまま同じスマホで動作確認できる、はず。

もう1つの課題は、家の外からPenpotを見る方法。
Penpotは家のネットワークからしか開けないので、スマホからはTailscaleで家のネットワークにつないで見る前提にしています。

まとめ

  • 家の中だけで使うPenpotでも、DNS-01とサブドメインの委任を組み合わせればちゃんとした証明書が付けられる
  • MCPサーバーは enable-mcp を追加して penpot-mcp を起動すれば使えるけど、ProxmoxのVMはCPUの種類に注意
  • モックを作る、モックに合わせて実装する、差分を確かめる、までを1つの会話の中で進められるようになった

スマホだけで開発が完結する日も近い・・・かもしれない。
できたらまた記事にします!

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?