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 ごとに分ける
全体はこうなる。

さっきの表と対応させると、共有するものと分けるものは次のとおり。
| 対象 | 全 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 に書く。
# 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 枚で示す。左が以前の形、右が今の形だ。

以前はポートをずらしていた
worktree を作るときのフックで、ほかの worktree の .env を読んで空いているオフセットを探し、8 種類のポートに同じ値を足して書き込んでいた。
# 旧フック(抜粋)。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 の開発環境の記事を書いた時点では、まだこの方式だった。動きはした。ただ、使い続けるうちに困ることが増えてきた。
- フックを通らない worktree は採番されない。
git worktree addで作った worktree や、AI エージェント(私は TAKT を使っている)が作る独立したクローンは既定のポートのまま起動して衝突した。エージェントが作業している途中の画面を見られなかったのが一番困った - ポートや URL を使う設定を足すたびにフックの修正が要り、追従漏れが出た。ベクトル DB のポート、メール内リンクの基点 URL、エディタのデバッグ設定が、それぞれ漏れていた
- URL がポート番号でしか区別できない。
localhost:3002がどのブランチなのか覚えていられない
仕組み: ホスト名で転送先を選ぶ
ホストに開くポートをリバースプロキシの 1 つだけにする。ブラウザは http://myapp-feat-a.localhost:3000 のようにホスト名を変えてアクセスし、プロキシはリクエストの Host ヘッダを見て、その worktree のコンテナへ転送する。

*.localhost は Chrome と Firefox が 127.0.0.1 に解決するので、/etc/hosts も DNS の設定も要らない。RFC 6761 §6.3 は localhost とそのサブドメインをループバックに解決するよう求めている。
プロキシには Traefik を使った。Traefik は Docker のコンテナに付いたラベルを読んで、転送のルールを自動で作る。worktree のアプリが起動すればルールが増え、止めれば消える。コンテナのラベルから設定を拾えるプロキシなら、Traefik でなくても同じ形は作れる。
設定
プロキシは、どの worktree にも属さない別のプロジェクトとして 1 つだけ起動する。
# 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 する。
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 で確認)。
# 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: を外す。
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 を外してから転送する。
# 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 もプロキシ越しにつながった。
動作確認
# この 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 という別名で参加させる。
# 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 ネットワークと、この共有ネットワークの両方に入る。
# compose/base.yaml(各 worktree)
services:
backend:
networks:
- default # 自分の worktree の frontend やベクトル DB と話す
- shared_db # 共有 PostgreSQL と話す
networks:
shared_db:
name: myapp_shared_db
external: true

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 で入れ直す。
# 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 つにまとめた。
# 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 つを順に実行すれば同じことになる。
./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 を登録している。
まとめ: 自分の環境に持ち込むチェックリスト
docker compose configの出力からports:とcontainer_name:を洗い出す。固定のcontainer_name:は外す- プロジェクト名の付け方を決める(小文字・ハイフン区切りで、ホスト名に使える形)。
.envを共有するなら、そこにCOMPOSE_PROJECT_NAMEを書かない - 共有するもの(DB など)を別のプロジェクトに切り出し、
docker network createで作ったネットワークに別名付きで参加させる。volume にはname:で固定の名前を付ける - リバースプロキシを別のプロジェクトで 1 つ立て、アプリにラベルを付けて
ports:を外す。ホストへの公開は127.0.0.1だけにする - frontend の API の基点を相対パス(
/api/v1)にし、CSRF などの許可元を<プロジェクト名>.localhostから組み立てる - 「共有 DB → プロキシ →
docker compose up -d --build」を 1 つのコマンドにまとめる
アプリをホストで直接起動しているなら portless、Mac で個人開発しているなら OrbStack のドメイン機能のほうが手軽だと思う。全部を Compose で動かしていて、作り方の違う worktree が混ざるなら、入口を 1 つにする形が合う。
フックが 100 行減ったのはおまけだった。変わったのは、エージェントが作業中の worktree を、ポート番号を調べずにプロジェクト名の URL で開けるようになったことだ。