YOSHIHIKO.TECH

<writing/>/DEV ENV/worktree-shared-reverse-proxy

AI 時代の並列開発の環境構築 — git worktree ごとにアプリを同時に動かす

git worktree の並列開発で、各 worktree のフロントとバックを同時に動かす Docker Compose 構成を紹介する。ポートはずらさず共有のリバースプロキシでホスト名に振り分け、DB は 1 つを共有ネットワークに置いて全 worktree から参照する。

  • PUBLISHED 2026.11.04
  • DEV ENV
  • 27 MIN READ
  • 7 SECTIONS
  • 13 CODE BLOCKS
[ CONTENTS ]
  1. 01やりたいこと: 各 worktree のアプリを同時に起動して確かめたい
  2. 02結論: 入口と DB は共有し、アプリは worktree ごとに分ける
  3. 03まず worktree ごとに名前を分ける(compose のプロジェクト名)
  4. 04ポート: ずらすのをやめ、入口を 1 つにしてホスト名で振り分ける
  5. 05DB: 1 つだけ立てて、全 worktree から同じ名前で参照する
  6. 06依存と起動: 1 コマンドで、worktree の作り方に依存しない
  7. 07まとめ: 自分の環境に持ち込むチェックリスト

AI エージェントに worktree を 1 つずつ渡して並列で開発していると、「この worktree のブランチで、画面と API がちゃんと動くか見たい」という場面が何度も来る。ところが同じアプリを 2 つ目、3 つ目と起動すると、ポートもコンテナ名も DB もぶつかる。

私が開発している Web アプリは、Next.js(frontend)、Go(backend)、Python(AI 用の API)、PostgreSQL、ベクトル DB を Docker Compose で動かしている。これまでは worktree を作るときのスクリプトでポートをずらしてしのいでいたが、2026 年 9 月にこれをやめ、共有のリバースプロキシでホスト名に振り分ける構成に変えた。この記事では、その構成を「何を共有し、何を worktree ごとに分けるか」でまとめる。

先に結論を書く。

  • 全 worktree で 1 つを共有するもの(入口のリバースプロキシ、PostgreSQL)と、worktree ごとに分けるもの(アプリのコンテナ、依存、ベクトル DB)を分ける
  • HTTP のポートは worktree ごとにずらさない。ホストに開くのはプロキシの 3000 番だけにして、http://<プロジェクト名>.localhost:3000 のホスト名で各 worktree に振り分ける
  • DB は共有ネットワークに 1 つ置き、どの worktree の backend も同じ接続先 db:5432 で参照する。ただし、互換性のない migration をブランチごとに並行して試す使い方には向かない
  • ポートずらしの頃と比べて、どの方法で作った worktree も同じ手順で起動でき、URL を見ればどの worktree か分かるようになった

検証環境は macOS、OrbStack(Docker 29.4、Compose 5.1.2)、Traefik v3.6 で、2026 年 9 月 29 日に確認した。Windows(WSL2)では試していない。

やりたいこと: 各 worktree のアプリを同時に起動して確かめたい

git worktree は、1 つのリポジトリから作業ディレクトリを複数作り、それぞれで別のブランチをチェックアウトしておける git の機能だ。ブランチを切り替えずに並行して作業できるので、AI エージェントを並べて動かすときによく使われる。私は作成と削除に git-worktree-runner(gtr)を使っている(git-worktree-runner の紹介記事)。

コードを書くのはエージェントに任せても、動くかどうかは自分の目で見たい。そこで worktree A と worktree B のアプリを同時に docker compose up すると、次のものがぶつかる。

ぶつかるもの 何が起きるか 解き方(章)
コンテナ名・ネットワーク・volume 後から起動した側が、同じ名前のコンテナを置き換える worktree ごとに compose のプロジェクト名を分ける(名前)
ホストのポート 2 つ目の frontend が 3000 番を取れずに起動しない 入口を 1 つにしてホスト名で振り分ける(ポート)
cookie ポートをずらしても、同じ localhost なので cookie は共有される worktree ごとにホスト名を変える(ポート)
DB worktree ごとに立てるとポートとコンテナ名で衝突し、データも別々になる 1 つを共有ネットワークに置く(DB)
依存(node_modules) 別の worktree が入れた依存で動いてしまう worktree ごとの volume に置く(依存と起動)

cookie はポートを区別しない(RFC 6265 §8.5)。ポートをずらすだけでは、ある worktree でログインした cookie が別の worktree にも送られる。

結論: 入口と DB は共有し、アプリは worktree ごとに分ける

全体はこうなる。

全体構成図。ブラウザは myapp-feat-a.localhost:3000 と myapp-feat-b.localhost:3000 でリバースプロキシにアクセスする。プロキシは全 worktree で共有し、ホストには 127.0.0.1:3000 だけを開く。プロキシは / を各 worktree の frontend に、/api/v1 を backend に転送する。worktree A と B はそれぞれ frontend・backend・ベクトル DB を持ち、両方の backend が全 worktree で共有する PostgreSQL(別名 db)に db:5432 で接続する

さっきの表と対応させると、共有するものと分けるものは次のとおり。

対象 全 worktree で 1 つを共有 worktree ごとに分ける
名前 — compose のプロジェクト名(ホスト名にも使う)
入口(HTTP のポート) リバースプロキシ(ホストの 3000 番) —
DB PostgreSQL と DB の管理画面 ベクトル DB
アプリ — frontend / backend / AI 用 API のコンテナ
依存 — node_modules の volume
設定 .env(同じ中身を使い回す) —

ここで compose の用語を補っておく。compose のプロジェクトは、docker compose が一緒に管理するコンテナ・ネットワーク・volume のまとまりで、プロジェクト名がそれぞれの名前の頭に付く。既定ではディレクトリ名がプロジェクト名になる。ネットワークのほうは、compose で external: true と宣言すると、別のプロジェクトが作ったものに参加できる。

共有するプロキシと DB は、それぞれ別のプロジェクトとして 1 つだけ起動する。各 worktree のプロジェクトは、そのネットワークに参加するだけだ。この記事の構成は、ほぼこれで全部である。

ただ、ホスト名で振り分けられるのは HTTP だけだ。PostgreSQL の 5432 番とデバッガのポートは固定のまま残る。

まず worktree ごとに名前を分ける(compose のプロジェクト名)

ポートより先に名前を分ける。compose が自動で付ける名前なら、コンテナ名・ネットワーク・volume の頭にプロジェクト名が付くので、プロジェクト名が worktree ごとに違えばぶつからない。例外は自分で固定した名前で、container_name: や、ネットワーク・volume の name: はプロジェクト名を変えても同じになる。私の環境ではベクトル DB と DB の管理画面に固定のコンテナ名が残っていたので、移行のときに外した(共有する DB とプロキシは、逆に固定の名前をわざと付けている)。

プロジェクト名は後でホスト名にも使うので、DNS のラベルとして使える形にする。gtr で作る worktree は、作成時のフックが <リポジトリ名>-<worktree のディレクトリ名> を小文字・ハイフン区切りにして .env に書く。

BASH5 LINES
# gtr の postCreate フック(抜粋)
project_name="${repo_name}-${worktree_name}"
project_name="$(printf '%s' "$project_name" \
  | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9' '-' | tr -s '-' | sed 's/^-//; s/-$//')"
printf 'COMPOSE_PROJECT_NAME=%s\n' "$project_name" >> "$WORKTREE_PATH/.env"

以前はアンダースコア区切りだったが、ホスト名にアンダースコアは使えないので変えた。gtr を通さずに作った worktree は、プロジェクト名を書かなければ compose の既定どおりディレクトリ名になる。

.env は全 worktree で同じ中身にした。worktree に .env が無ければ、起動タスクがメインのチェックアウトの .env へシンボリックリンクを張る。このとき、共有元の .env に COMPOSE_PROJECT_NAME が書かれていると全 worktree が同じプロジェクトになってしまうので、その場合はリンクせずに止める。起動の直前には、同じプロジェクト名が別のディレクトリのコンテナに使われていないかも確かめている。

ポート: ずらすのをやめ、入口を 1 つにしてホスト名で振り分ける

この章でやることを先に 1 枚で示す。左が以前の形、右が今の形だ。

ポートずらしと入口 1 つの比較。Before では main・worktree A・worktree B がそれぞれ frontend を 3000・3001・3002、backend を 8080・8081・8082 で公開し、作成フックが 8 種類のポートを採番するため、フックを通らない worktree は衝突する。After ではリバースプロキシの 3000 番だけを開き、myapp.localhost・myapp-feat-a.localhost・myapp-feat-b.localhost のホスト名で各 worktree に振り分ける。各 worktree は ports を持たず、作り方を問わない

以前はポートをずらしていた

worktree を作るときのフックで、ほかの worktree の .env を読んで空いているオフセットを探し、8 種類のポートに同じ値を足して書き込んでいた。

BASH4 LINES
# 旧フック(抜粋)。8 種類のポートに同じオフセットを足して .env に書く
PORT_KEYS=(FRONTEND_PORT STORYBOOK_PORT BACKEND_PORT AI_GATEWAY_PORT
           DB_GUI_PORT FRONTEND_DEBUG_PORT BACKEND_DEBUG_PORT AI_GATEWAY_DEBUG_PORT)
PORT_BASES=(3000 6006 8080 8000 5050 9229 40000 5678)

2 つ目の worktree なら frontend が 3001、backend が 8081、という具合だ。Ghostty + herdr の開発環境の記事を書いた時点では、まだこの方式だった。動きはした。ただ、使い続けるうちに困ることが増えてきた。

  1. フックを通らない worktree は採番されない。git worktree add で作った worktree や、AI エージェント(私は TAKT を使っている)が作る独立したクローンは既定のポートのまま起動して衝突した。エージェントが作業している途中の画面を見られなかったのが一番困った
  2. ポートや URL を使う設定を足すたびにフックの修正が要り、追従漏れが出た。ベクトル DB のポート、メール内リンクの基点 URL、エディタのデバッグ設定が、それぞれ漏れていた
  3. URL がポート番号でしか区別できない。localhost:3002 がどのブランチなのか覚えていられない

仕組み: ホスト名で転送先を選ぶ

ホストに開くポートをリバースプロキシの 1 つだけにする。ブラウザは http://myapp-feat-a.localhost:3000 のようにホスト名を変えてアクセスし、プロキシはリクエストの Host ヘッダを見て、その worktree のコンテナへ転送する。

ホスト名とパスで転送先を選ぶ図。myapp-feat-a.localhost:3000/ は worktree A の frontend へ、myapp-feat-a.localhost:3000/api/v1/ 以下は /api/v1 を外して worktree A の backend へ、myapp-feat-b.localhost:3000/ は worktree B の frontend へ転送される。リバースプロキシは Host ヘッダとパスを見て、ルールはコンテナのラベルから自動で作る。画面と API が同じホスト名なので同じオリジンになり、cookie と CSRF の設定が 1 つで済む

*.localhost は Chrome と Firefox が 127.0.0.1 に解決するので、/etc/hosts も DNS の設定も要らない。RFC 6761 §6.3 は localhost とそのサブドメインをループバックに解決するよう求めている。

プロキシには Traefik を使った。Traefik は Docker のコンテナに付いたラベルを読んで、転送のルールを自動で作る。worktree のアプリが起動すればルールが増え、止めれば消える。コンテナのラベルから設定を拾えるプロキシなら、Traefik でなくても同じ形は作れる。

設定

プロキシは、どの worktree にも属さない別のプロジェクトとして 1 つだけ起動する。

YAML24 LINES
# compose/proxy.yaml(共有リバースプロキシ)
services:
  proxy:
    image: traefik:v3.6 # 実際はダイジェストで固定している
    container_name: myapp_proxy
    restart: unless-stopped
    command:
      - --providers.docker=true
      # traefik.enable=true のラベルを付けたコンテナだけを公開する
      - --providers.docker.exposedbydefault=false
      - --providers.docker.network=myapp_shared_proxy
      - --entrypoints.web.address=:80
    ports:
      - "127.0.0.1:3000:80" # LAN には公開しない
    volumes:
      # コンテナのラベルを読むため
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks:
      - shared_proxy

networks:
  shared_proxy:
    name: myapp_shared_proxy
    external: true

起動するときは、ネットワークが無ければ作ってから、固定のプロジェクト名で up する。

BASH3 LINES
docker network inspect myapp_shared_proxy >/dev/null 2>&1 \
  || docker network create myapp_shared_proxy
docker compose --project-name myapp_shared_proxy -f compose/proxy.yaml up -d proxy

各 worktree の側は、アプリのコンテナにラベルを付けて共有ネットワークに参加させる。ホスト名にプロジェクト名を埋め込むので、同じ定義のまま worktree ごとに別のホスト名になる。COMPOSE_PROJECT_NAME を .env に書いていない worktree でも、compose がプロジェクト名を入れて展開した(Compose 5.1.2 で確認)。

YAML19 LINES
# compose/routing.yaml(各 worktree の frontend をプロキシに登録する)
services:
  frontend:
    environment:
      - NEXT_PUBLIC_API_BASE_URL=/api/v1
    labels:
      - traefik.enable=true
      - traefik.docker.network=myapp_shared_proxy
      - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-frontend.rule=Host(`${COMPOSE_PROJECT_NAME}.localhost`)"
      - traefik.http.routers.${COMPOSE_PROJECT_NAME}-frontend.entrypoints=web
      - traefik.http.services.${COMPOSE_PROJECT_NAME}-frontend.loadbalancer.server.port=3000
    networks:
      - default
      - shared_proxy

networks:
  shared_proxy:
    name: myapp_shared_proxy
    external: true

そして、アプリ側からは ports: を外す。

DIFF7 LINES
 services:
   frontend:
-    ports:
-      - "${FRONTEND_PORT:-3000}:3000"
   backend:
-    ports:
-      - "${BACKEND_PORT:-8080}:8080"

これでホストに開く HTTP のポートは、プロキシの 3000 番だけになった。URL の :3000 は全 worktree で同じだが、ホスト名が違うので行き先も違う。

フロントと API を同じオリジンにする

ホスト名で振り分けるついでに、1 つのホスト名をパスで分けて、/api/v1 から始まるリクエストを backend に送るようにした。backend のルートは / 直下にあるので、プロキシで /api/v1 を外してから転送する。

YAML20 LINES
# compose/routing.yaml(続き)
backend:
  environment:
    # 許可元はこの worktree の URL 1 つだけ
    - AUTH_CSRF_TRUSTED_ORIGINS=http://${COMPOSE_PROJECT_NAME}.localhost:3000
    # メール内リンクの基点もこの worktree の URL にそろえる
    - APP_BASE_URL=http://${COMPOSE_PROJECT_NAME}.localhost:3000
  labels:
    - traefik.enable=true
    - traefik.docker.network=myapp_shared_proxy
    # /api/v10 などを拾わないよう、Path と PathPrefix を分けて書く
    - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-api.rule=Host(`${COMPOSE_PROJECT_NAME}.localhost`) && (Path(`/api/v1`) || PathPrefix(`/api/v1/`))"
    - traefik.http.routers.${COMPOSE_PROJECT_NAME}-api.entrypoints=web
    - traefik.http.routers.${COMPOSE_PROJECT_NAME}-api.middlewares=${COMPOSE_PROJECT_NAME}-api-strip
    - traefik.http.middlewares.${COMPOSE_PROJECT_NAME}-api-strip.stripprefix.prefixes=/api/v1
    - traefik.http.services.${COMPOSE_PROJECT_NAME}-api.loadbalancer.server.port=8080
  networks:
    - default
    - shared_db # 共有 PostgreSQL 用(DB の章)
    - shared_proxy

frontend は API の基点を相対パスの /api/v1 にしてあるので、どの worktree でも自分の backend を呼ぶ。変更前後の URL を並べるとこうなる。

状態 画面 API オリジン
変更前(2 つ目の worktree) http://localhost:3001 http://localhost:8081 別(ポートが違う)
変更後 http://myapp-feat-a.localhost:3000/ http://myapp-feat-a.localhost:3000/api/v1/ 同じ

同じオリジンになって、cookie と CSRF の扱いが単純になった。backend は refresh cookie を Domain 属性なし(ホスト限定)・Path=/ で発行している。画面へのリクエストにもこの cookie が付くので、frontend の middleware がそのまま読める。ホスト名が worktree ごとに違うので、cookie も worktree ごとに分かれる。CSRF の許可元も、以前はフックが worktree ごとのポートから組み立てて .env に書いていたが、今は compose がプロジェクト名から組み立てる。

Next.js の dev サーバーは localhost とそのサブドメインを既定で許可するので、allowedDevOrigins は設定していない。ホットリロード(HMR)の WebSocket もプロキシ越しにつながった。

動作確認

BASH5 LINES
# この worktree のプロジェクト名(= ホスト名)
docker compose config | sed -n '1s/^name: //p'   # → myapp-feat-a

curl -s -o /dev/null -w '%{http_code}\n' http://myapp-feat-a.localhost:3000/          # frontend
curl -s -o /dev/null -w '%{http_code}\n' http://myapp-feat-a.localhost:3000/api/v1/   # backend

どちらも 200 が返れば振り分けはできている。curl は 7.85.0 から *.localhost を自分でループバックアドレス(127.0.0.1 と ::1)に解決する。

ほかの解き方と比べて

この形に落ち着くまでには回り道があった。きっかけは Vercel Labs の portless だ。ホストで起動した dev サーバーに myapp.localhost のような名前付きの URL を付けるツールで、git worktree の中ではブランチ名をホスト名に入れてくれる。紹介記事を読んで、これを入れればポートずらしのフックを消せるのではないかと思い、調べ始めた。

調べてみると、portless の自動の機能(PORT の割り当てや、ブランチ名からのホスト名)は、portless 経由で起動したプロセスにしか効かない。私の環境はアプリを全部コンテナで動かしているので、そのままでは効かない。portless alias <名前> <ポート> で、コンテナがホストに公開したポートに名前を付けることはできる。ただ、公開するポートを worktree ごとにずらす作業は残る。フックを消せないなら、入れる意味は薄い。

次に見たのが、Docker の実行環境に使っている OrbStack のドメイン機能だ。Compose のサービスに service.project.orb.local の名前が自動で付き、設定はほとんど要らない。ただ OrbStack は macOS 向けのアプリで、商用利用には有料のライセンスが要る。Windows の人もいるチームで標準にするのは難しかった。

そこで、全部 Compose のまま、プロキシも Compose で 1 つ立てる形にした。判断の基準はこう置いた。

  • Mac と Windows で同じ手順で動く
  • Docker で環境を再現できる状態を保つ
  • worktree の作り方(gtr、git worktree add、エージェントのクローン)に依存しない
  • CI の起動経路を壊さない
方式 前提 今回合わなかった点 入れるときのコスト
ポートをずらす(従来) worktree 作成時にフックを通す フックを通らない worktree が漏れる。設定の追従漏れ フックの保守
portless(0.9.0 以降の既定) アプリを portless 経由でホストで起動 自動の機能がコンテナに効かず、ポートの採番が残る 443 番に sudo で常駐する。ローカル CA をトラストストアに入れる
OrbStack のドメイン OrbStack を Docker の実行環境に使う macOS 向けで、商用利用は有料 ほぼ設定不要
devcontainer エディタからコンテナに入って開発する ツールの再現性はそろうが、ポートの問題は解かない 設定ファイル一式
共有リバースプロキシ(採用) Docker(実行環境は問わない) — 3000 番を 1 つ占有する。HTTP 以外は対象外

アプリをホストで直接起動している構成なら、portless はかなり手軽な選択肢だと思う。私の場合に合わなかったのは、全部を Compose で動かしていたからだ。

ポートずらしの頃と比べて変わったこと

項目 ポートずらし 共有リバースプロキシ
URL http://localhost:3001(番号で区別) http://myapp-feat-a.localhost:3000(名前で区別)
ホストに開く HTTP のポート worktree ごとに最大 6 つ(ツール用を含む) 全体でプロキシの 1 つ
衝突しない worktree gtr で作ったものだけ 作り方を問わない
worktree 作成フック 169 行(大半がポートの採番) 69 行(名前の設定と依存の install)
CSRF の許可元 フックがポートから生成して .env に書く compose がホスト名から組み立てる
LAN への公開 frontend などを全インターフェースに公開 127.0.0.1 だけ

DB: 1 つだけ立てて、全 worktree から同じ名前で参照する

PostgreSQL は全 worktree で 1 つを共有している。これはプロキシより前、2026 年 7 月から続けている構成だ。

仕組み

共有の DB も、プロキシと同じく別のプロジェクトとして 1 つだけ起動し、専用のネットワーク myapp_shared_db に db という別名で参加させる。

YAML23 LINES
# compose/db.yaml(共有 PostgreSQL)
services:
  db:
    image: postgres:16-alpine
    container_name: myapp_postgres
    restart: always
    ports:
      - "127.0.0.1:5432:5432" # ホストの psql などから使う。LAN には公開しない
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      shared_db:
        aliases:
          - db

volumes:
  postgres_data:
    name: myapp_postgres_data # プロジェクト名に左右されない固定の名前

networks:
  shared_db:
    name: myapp_shared_db
    external: true

各 worktree の backend は、自分のプロジェクトの default ネットワークと、この共有ネットワークの両方に入る。

YAML11 LINES
# compose/base.yaml(各 worktree)
services:
  backend:
    networks:
      - default # 自分の worktree の frontend やベクトル DB と話す
      - shared_db # 共有 PostgreSQL と話す

networks:
  shared_db:
    name: myapp_shared_db
    external: true

共有 DB のネットワーク図。worktree A と worktree B のネットワークにはそれぞれ frontend・ベクトル DB・backend がある。backend だけが自分の worktree のネットワークと共有ネットワーク(myapp_shared_db)の両方に入り、共有ネットワーク上の PostgreSQL(別名 db)に db:5432 で接続する。接続先はどの worktree でも db:5432 で、frontend とベクトル DB は DB に触れない

Docker のネットワークの中では、コンテナをサービス名や別名で引ける。backend から見ると、共有ネットワークの上に db という名前のホストが 1 つあるだけだ。接続文字列は postgres://...@db:5432/... のまま、どの worktree でも変えずに済む。frontend とベクトル DB は共有ネットワークに入らないので、DB には直接触れない。

以前は DB を各 worktree の compose に入れていた。volume はプロジェクトごとに分かれるので、同じ DB を使うつもりでもデータは別々になり、2 つ目の worktree を起動すると固定のコンテナ名と 5432 番で衝突した。DB を別のプロジェクトに切り出し、volume に固定の名前を付けたのはそのためだ。

なぜネットワークを共有するのか

DB の持ち方は、ほかにも考えられる。並べるとこうなる。

方式 良いところ 困るところ
worktree ごとに DB を立てる スキーマとデータがブランチ間で干渉しない DB の数だけメモリとディスクを使う。データを使い回せない
ホスト経由でつなぐ(host.docker.internal) ネットワークを共有しなくてよい 接続先の書き方が OS や Docker の実行環境に依存する
共有ネットワークに置く(採用) 接続先 db:5432 を変えずに 1 つを使い回せる migration やデータの変更が全 worktree に効く

共有にしておくと、worktree を作るたびにテスト用のユーザーやデータを作り直さずに済む。ホストから見たいときは 127.0.0.1:5432 で psql などをつなぐ。

共有して困るのは、スキーマが分かれるとき

共有 DB には向かない使い方がある。互換性のない migration を、ブランチごとに並行して試す使い方だ。

  • migration ツールが記録する版が、先に適用したブランチの版まで進む。その migration ファイルを持たない別のブランチでは、ツールが版を解決できずに止まることがある
  • あるブランチで migration を戻す(down)と、そのテーブルを使っている別のブランチが壊れる
  • DB をリセットすると、全 worktree のデータが消える

今のところ、これは README で「migration、DB のリセット、手動のデータ変更は、起動中の全 worktree に影響する」と注意しているだけで、仕組みでは防いでいない。

中間の案もある。PostgreSQL のコンテナは 1 つのまま、worktree ごとに database(または schema)を分ける方法だ。CREATE DATABASE myapp_feat_a のように worktree ごとの DB を作り、接続文字列の DB 名だけを変える。データは使い回せなくなるが、スキーマは干渉しない。この案は当時の比較に入れていなかった。スキーマを変える作業を並行することが増えたら、次の候補になる。

ベクトル DB は worktree ごとに分けた

一方、ベクトル DB(Qdrant)は worktree ごとに持たせた。共有すると困ることがあるからだ。

  • E2E テストでは LLM を偽物に差し替えていて、意味のないベクトルが作られる。共有すると、それが実データに混ざる
  • ブランチで埋め込みの方式やコレクションの形式を変えると、その変更が全 worktree に効いてしまう

この組み合わせには副作用がある。worktree A で登録したデータは PostgreSQL に入るので、worktree B の画面にも出る。ところがベクトルは A のベクトル DB にしか無いので、B の検索では使えない。B で使いたければ、B で索引を作り直す。

共有するか分けるかの目安

DB に限らず、何を共有するかは次の観点で決めた。

  • 共有する: ブランチをまたいでデータを使い回したい。スキーマの変更がまれ。起動の手間とリソースを減らしたい
  • 分ける: ブランチで形式やスキーマが変わる。テスト用のデータが混ざると困る。作り直しが安い

PostgreSQL は前者、ベクトル DB と node_modules は後者だった。

依存と起動: 1 コマンドで、worktree の作り方に依存しない

node_modules は worktree ごとの volume に置く

イメージ名は全 worktree で共有している。worktree ごとに名前を分けると、消した worktree の分だけイメージが残るからだ。その代わり、起動のたびにその worktree のソースからビルドし直す(差分が無ければキャッシュですぐ終わる)。node_modules はイメージの中のものを使わず、worktree ごとの名前付き volume に置いて、起動時にその worktree の lockfile で入れ直す。

YAML12 LINES
# compose/override.yaml(ローカル開発用)
services:
  frontend:
    # lockfile が変わっていなければ数秒で終わる
    command: sh -c "pnpm install --frozen-lockfile && exec pnpm run dev"
    volumes:
      - ./frontend:/app
      # volume 名にプロジェクト名が付くので、worktree ごとに分かれる
      - frontend_node_modules:/app/node_modules

volumes:
  frontend_node_modules:

pnpm の store もこの volume の中に置いた(Dockerfile で npm_config_store_dir=/app/node_modules/.pnpm-store)。ハードリンクが効き、worktree を消せば volume と一緒に消える。2 回目以降の install は 1〜4 秒ほどで、volume が空の初回は十数秒かかる。

起動の流れ

起動はタスクランナー(Task)の task compose:up 1 つにまとめた。

YAML8 LINES
# taskfiles/Taskfile.compose.yml(抜粋)
up:
  cmds:
    - task: env:link # .env が無ければメインの .env にリンク
    - task: project:check # プロジェクト名が別のディレクトリで使われていないか
    - task: db:up # 共有 PostgreSQL(無ければ作り、あれば使う)
    - task: proxy:up # 共有プロキシ(同上)
    - docker compose up -d --build # この worktree のアプリ

Task を使わない場合は、次の 3 つを順に実行すれば同じことになる。

BASH3 LINES
./scripts/shared-db.sh up      # 共有ネットワーク + PostgreSQL(別名 db)
./scripts/shared-proxy.sh up   # 共有ネットワーク + Traefik
docker compose up -d --build   # この worktree のアプリ

共有の DB とプロキシは「無ければ作り、あれば使う」ので、最初に起動した worktree が作り、2 つ目以降はそれを使う。gtr で作った worktree でも、git worktree add で作ったものでも、AI エージェントのクローンでも、同じ task compose:up で起動できる。前提は、プロジェクト名が worktree ごとに違うこと。ディレクトリ名が同じ別のクローンなどで名前が重なったら、起動前のチェックが止める。名前を自動で付け直してくれるわけではない。エージェントが作業している途中の画面も、プロジェクト名の URL で開ける。

片付け

gtr で worktree を消すときは、削除前のフックで docker compose down --volumes を流す。gtr を通さずに消した worktree の残骸(コンテナ・ネットワーク・volume)は、task compose:prune で一覧にしてから消す。作業ディレクトリが存在しないプロジェクトを探し、共有の DB とプロキシは名前で除外する。不要になったイメージも、自分のプロジェクトのラベルが付いたものだけを消す(docker image prune はホスト全体が対象になるので使わない)。

CI の E2E も同じ経路にした

最初は CI だけ、frontend と backend をポートで公開した別オリジンのまま E2E を動かしていた。これだとローカルと CI で cookie と CSRF の条件がそろわない。そこで CI でもプロキシを起動し、http://myapp-ci.localhost:3000 で E2E を流すようにした。GitHub Actions の runner では、Playwright の API リクエストなど OS の名前解決を使うクライアントのために、/etc/hosts に myapp-ci.localhost を登録している。

まとめ: 自分の環境に持ち込むチェックリスト

  1. docker compose config の出力から ports: と container_name: を洗い出す。固定の container_name: は外す
  2. プロジェクト名の付け方を決める(小文字・ハイフン区切りで、ホスト名に使える形)。.env を共有するなら、そこに COMPOSE_PROJECT_NAME を書かない
  3. 共有するもの(DB など)を別のプロジェクトに切り出し、docker network create で作ったネットワークに別名付きで参加させる。volume には name: で固定の名前を付ける
  4. リバースプロキシを別のプロジェクトで 1 つ立て、アプリにラベルを付けて ports: を外す。ホストへの公開は 127.0.0.1 だけにする
  5. frontend の API の基点を相対パス(/api/v1)にし、CSRF などの許可元を <プロジェクト名>.localhost から組み立てる
  6. 「共有 DB → プロキシ → docker compose up -d --build」を 1 つのコマンドにまとめる

アプリをホストで直接起動しているなら portless、Mac で個人開発しているなら OrbStack のドメイン機能のほうが手軽だと思う。全部を Compose で動かしていて、作り方の違う worktree が混ざるなら、入口を 1 つにする形が合う。

フックが 100 行減ったのはおまけだった。変わったのは、エージェントが作業中の worktree を、ポート番号を調べずにプロジェクト名の URL で開けるようになったことだ。