A stack perworktree.

Zero-config Docker Compose isolation for git worktrees: every worktree gets its own ports, containers and volumes, from the compose file you already have.

#1 Product of the Week on DevHuntOpen source, MITworktree-compose on npm

then wtc start in any repo with worktrees

The problem

  • One compose file.
  • Two worktrees.
  • Same host ports.
  • Same database.
  • The second fails.
~/myapp-billing
$ docker compose up -d Container myapp-billing-postgres-1 ErrorError response from daemon: Bind for 0.0.0.0:5434 failed: port is already allocated $ grep DATABASE_URL .envDATABASE_URL=postgresql://app:app@localhost:5434/app

docker compose -p only renames the project. You would still pick free ports, edit every worktree's .env, copy compose files across and tear it all down by hand. wtc does each of those for you. A URL with the port inside it, like that DATABASE_URL, takes one line of config.

The whole loop in 46 seconds

Four agents, four worktrees, four stacks on their own ports. Compare them live, promote the winner into main, then clean up.

Same file, new ports

wtc reads every host port written as ${VAR:-default} and gives each worktree its own. Pick a service, a worktree and a stride, or type your own port, and check the arithmetic.

"${FRONTEND_PORT:-5173}:5173"20000 + 5173 + 1 × 1 = 25174FRONTEND_PORT=25174
Service in docker-compose.yml
Worktree
portStride

Defaults that sit close together, like mocks on 8081 to 8086, can collide between worktrees at stride 1. wtc warns when it sees that and recommends the smallest portStride that keeps them apart.

From worktree to stack

First, write host ports as ${VAR:-default}.

docker-compose.yml
services:  postgres:    image: postgres:16    ports:      - "${POSTGRES_PORT:-5434}:5432"  backend:    build: ./backend    ports:      - "${BACKEND_PORT:-8000}:8000"  frontend:    build: ./frontend    ports:      - "${FRONTEND_PORT:-5173}:5173"

Main still gets 5434, 8000 and 5173. A hardcoded port gets a warning with the fix:

 Service "nginx" uses a raw port mapping (8080:80).  To enable port isolation, change it to: "${NGINX_PORT:-8080}:80"

Then add a worktree and start it.

~/myapp
$ git worktree add ../myapp-auth feature-auth$ wtc start feature-auth

wtc copies the env file from main, writes the worktree's ports into it and runs compose up. Keys main already has, like POSTGRES_PORT, are rewritten in place; new ones go in a marked block. The copy is made fresh on every start, so edits made only in the worktree's env file are replaced: keep lasting values in main's, or in envOverrides when they come from a port.

../myapp-auth/.env
NODE_ENV=development # --- wtc port overrides ---COMPOSE_PROJECT_NAME=myapp-wt-1-feature-authPOSTGRES_PORT=25435BACKEND_PORT=28001FRONTEND_PORT=25174# --- end wtc ---

With COMPOSE_PROJECT_NAME in .env, a plain docker compose ps, your Makefile and your own scripts in the worktree reach this stack without wtc. Compose reads no other env file on its own, so with a custom envFile like .env.development, add --env-file .env.development.

Now every stack runs side by side.

$ wtc list

Open 25174 and 25175 in two tabs and compare the branches while both run.

┌───────┬──────────────┬────────┬────────────────────────┬────────────────────────────────────────────────────────────┐
│ Index │ Branch       │ Status │ URL                    │ Ports                                                      │
├───────┼──────────────┼────────┼────────────────────────┼────────────────────────────────────────────────────────────┤
│ -     │ main         │ -      │ -                      │ postgres:5434 backend:8000 frontend:5173                   │
├───────┼──────────────┼────────┼────────────────────────┼────────────────────────────────────────────────────────────┤
│ 1     │ feature-auth │ up     │ http://localhost:25174 │ POSTGRES_PORT=25435 BACKEND_PORT=28001 FRONTEND_PORT=25174 │
├───────┼──────────────┼────────┼────────────────────────┼────────────────────────────────────────────────────────────┤
│ 2     │ fix-billing  │ up     │ http://localhost:25175 │ POSTGRES_PORT=25436 BACKEND_PORT=28002 FRONTEND_PORT=25175 │
├───────┼──────────────┼────────┼────────────────────────┼────────────────────────────────────────────────────────────┤
│ 3     │ refactor-api │ down   │ http://localhost:25176 │ POSTGRES_PORT=25437 BACKEND_PORT=28003 FRONTEND_PORT=25176 │
└───────┴──────────────┴────────┴────────────────────────┴────────────────────────────────────────────────────────────┘

Scroll the table sideways for the URLs and ports.

Remove one, and the rest keep their numbers.

$ wtc stop 2$ git worktree remove ../myapp-billing$ wtc list

Worktree 3 is still worktree 3, on the same ports, with the same containers and volumes. wtc records each worktree's index and Compose project name the first time it sees it, and the ports follow from the index. Delete a worktree without stopping it and wtc list shows it as (removed) until wtc prune tears its stack down.

┌───────┬──────────────┬────────┬────────────────────────┬────────────────────────────────────────────────────────────┐
│ Index │ Branch       │ Status │ URL                    │ Ports                                                      │
├───────┼──────────────┼────────┼────────────────────────┼────────────────────────────────────────────────────────────┤
│ -     │ main         │ -      │ -                      │ postgres:5434 backend:8000 frontend:5173                   │
├───────┼──────────────┼────────┼────────────────────────┼────────────────────────────────────────────────────────────┤
│ 1     │ feature-auth │ up     │ http://localhost:25174 │ POSTGRES_PORT=25435 BACKEND_PORT=28001 FRONTEND_PORT=25174 │
├───────┼──────────────┼────────┼────────────────────────┼────────────────────────────────────────────────────────────┤
│ 3     │ refactor-api │ down   │ http://localhost:25176 │ POSTGRES_PORT=25437 BACKEND_PORT=28003 FRONTEND_PORT=25176 │
└───────┴──────────────┴────────┴────────────────────────┴────────────────────────────────────────────────────────────┘

Agents run their own stacks

wtc mcp is a built-in MCP server. Claude Code, Codex, Cursor or any MCP client can start, list, promote and clean up worktree stacks itself, while you compare the results on their own ports.

Register it once, for all your projects:

terminal
$ claude mcp add --scope user wtc -- wtc mcp

Or share it with the team in the project's .mcp.json:

.mcp.json
{  "mcpServers": {    "wtc": {      "command": "wtc",      "args": ["mcp"]    }  }}

Any other MCP client: run wtc mcp as a stdio server. This needs the global install, so wtc is on your PATH.

A tool for each command

wtc_start, wtc_stop, wtc_restart, wtc_list, wtc_promote, wtc_clean, wtc_prune and wtc_doctor take the same worktree indices and branch names as the commands. Like the commands, wtc_clean and wtc_prune delete volumes, and wtc_clean keeps worktrees with uncommitted work.

Or teach it the workflow

SKILL.md is an agent skill that covers the commands, port allocation, config and the MCP server in one file. Install it for Claude Code with one command, or hand it to any agent that reads skills.

terminal
$ wtc skill --install# writes ~/.claude/skills/worktree-compose/SKILL.md
DownloadRead it

Commands

Filter by name or by what a command does, then use the arrow keys or click a command to read it. Every command takes --help.

  • Syncs the compose file, Dockerfiles and env files from main, writes the worktree's ports into its env file and runs compose up -d --build. Name worktrees by index, branch, directory or path, or . for the one you are in; name none to start them all. --no-build skips the rebuild.

    $ wtc start feature-auth
  • Stops the containers of the chosen stacks, or of every stack when you name none. Containers and volumes are kept, so wtc start resumes them with their data.

    $ wtc stop 2
  • Stops, re-syncs files, re-injects env, rebuilds and starts again, keeping the data. Use it after migrations, Dockerfile edits or config changes.

    $ wtc restart .
  • Every worktree with its branch, status, URL and ports. With --json it prints the same data for scripts and agents.

    $ wtc list --json
  • Copies a worktree's added, changed and deleted files into your current checkout as uncommitted changes. Local env files and compose files stay behind. It stops if a file it would touch has local edits, or if your branch changed that file since the worktree branched; --force overrides the second check.

    $ wtc promote feature-auth
  • Runs compose down -v on every stack this checkout owns, which deletes their volumes and the data in them, then removes the worktrees and sweeps leftovers it can prove are its own. Worktrees with uncommitted work, commits on no branch, unpushed submodule commits or a lock are kept unless you pass --force. --keep-worktrees keeps the checkouts but still deletes the volumes; to stop everything and keep the data, run wtc stop. --sweep also removes stacks left by worktrees whose directory is gone, such as wtc 0.2 leftovers. --dry-run shows the plan first.

    $ wtc clean --dry-run
  • Deleted a worktree before stopping it? prune tears down the stack it left behind, containers, networks and volumes, and forgets the worktree.

    $ wtc prune
  • Checks git, the compose command and its engine, and isolation: host ports that are not isolated, container_name values that would clash between stacks, named volumes shared across worktrees, and a daemon that does not answer.

    $ wtc doctor
  • Prints the bundled agent skill. --install writes it to ~/.claude/skills/worktree-compose, so Claude Code loads it in every project; --dir puts it under another skills folder.

    $ wtc skill --install
  • Starts the MCP server on stdio, so an agent can start, stop, list, promote and clean up stacks on its own.

    $ wtc mcp

Config when you need it

Most repos need none. wtc reads docker-compose.yml, follows its include: files and syncs every Dockerfile a service builds. It finds the compose command on its own, for Docker Compose v2, for Podman through podman compose or podman-compose, and for Docker-compatible engines such as OrbStack and Colima. When a project needs more, it goes in .wtcrc.json at the repo root, or a "wtc" key in package.json.

In a monorepo the env files live next to each app. Name the file and the workspaces, and every worktree gets its own copies, injected, with the root always included:

~/myapp
$ wtc start --env .env.development --workspace server --workspace homepage
.wtcrc.json
{  "envFile": ".env.development",  "workspaces": ["server", "homepage"],  "envOverrides": {    "DATABASE_URL": "postgresql://app:app@localhost:${POSTGRES_PORT}/app"  },  "workspaceEnvOverrides": {    "homepage": {      "VITE_API_URL": "http://localhost:${BACKEND_PORT}"    }  },  "sync": [".generated/prisma-client", "local-certs/"],  "portStride": 100,  "composeCommand": "podman compose"}
envFile
The env file to copy from main and inject into, at the root and in each selected workspace. Defaults to .env. Per run: --env.
workspaces
Monorepo packages that also get their env file copied and injected, by package name, directory or path. Per run: --workspace, repeatable.
envOverrides
Extra variables for the root env file, with ${VAR} filled in from that worktree's ports. A URL with a port in it, like DATABASE_URL, goes here. Keys that already exist are rewritten in place; new ones go in the wtc block.
workspaceEnvOverrides
The same, for one workspace's env file.
sync
Gitignored or generated files Docker needs, copied from main on every start: generated clients, local certificates.
portStride
Spacing between worktrees' ports, 1 by default. Raise it when default ports sit close together.
composeCommand
The compose command to run when you want to choose it yourself, such as podman compose. The WTC_COMPOSE_COMMAND variable does the same from the shell.