Imported from susancal/worktree-stacks (
plugins/worktree-stacks/skills/worktree-stacks/SKILL.md). Install upstream withnpx skills add susancal/worktree-stacks --skill worktree-stacks. Copyright stays with the author.
Per-worktree docker compose stacks
Run every git worktree as its own docker compose stack, on its own ports, with its own database, and have a new worktree start itself.
The problem
A compose file with fixed host ports ("8000:8000") can only run once. The
second worktree fails to bind. Two things actually collide:
- Host ports are global to the machine.
container_name:is global to the docker daemon, so a fixedcontainer_nameblocks a second stack even with different ports and a different project name. This one surprises people.
A third issue is silent rather than loud: without COMPOSE_PROJECT_NAME, a
bare docker compose exec run inside worktree B can target worktree A's
containers, because the project name comes from the compose file or directory.
The port scheme
Give each worktree a numbered slot. Slot N owns the block 8N00-8N19:
| slot 0 (primary) | slot 1 | slot 2 | |
|---|---|---|---|
| web app | 8000 | 8100 | 8200 |
| db admin UI | 8001 | 8101 | 8201 |
| mail catcher | 8004 | 8104 | 8204 |
| postgres | 5432 | 8110 | 8210 |
The hundreds digit is the worktree; the last digit is always the same service.
Once you learn "…04 is always mail", you never look it up again. Slot 0 is
the primary worktree and keeps the project's historical ports, which are also
the compose defaults, so nothing changes for anyone who does not opt in.
Prefer this over a hash-based port (worktrunk ships a hash_port filter):
hashing needs no bookkeeping but gives unmemorable, unpredictable numbers.
Slots stay stable and readable, at the cost of tracking which slot is taken.
Steps
1. Parameterize the compose file
Remove every container_name: line, and publish each host port through a
variable whose default is today's value:
services:
webapp:
ports:
- "${WEBAPP_PORT:-8000}:8000"
postgres:
ports:
- "${POSTGRES_PORT:-5432}:5432"
Keep any top-level name:; COMPOSE_PROJECT_NAME overrides it. Compose
precedence is -p flag > COMPOSE_PROJECT_NAME > top-level name: >
directory name.
Verify defaults did not drift, then that overrides win:
docker compose config --format json | jq -r '.name, .services.webapp.ports[0].published'
docker compose config | grep -c container_name # must be 0
COMPOSE_PROJECT_NAME=x WEBAPP_PORT=8300 docker compose config --format json | jq -r '.name, .services.webapp.ports[0].published'
2. Install the slot script
Copy scripts/worktree_ports.sh from this skill into the project (e.g.
scripts/), and edit its SERVICES array to match the project's published
services. Each entry is ENV_VAR:offset-within-slot:port-used-by-slot-0.
It writes a marker-delimited block into the worktree's .env holding
WORKTREE_SLOT, COMPOSE_PROJECT_NAME, one variable per service, and
APP_BASE_URL. It is idempotent and refuses to reuse a slot another worktree
claims.
Test all three paths before trusting it: this worktree gets a nonzero slot, running it twice keeps one block and the same slot, and the primary worktree gets slot 0 with the original ports.
Slot numbers are not necessarily dense. The script skips any slot whose base port is already bound by anything on the machine, including an unrelated project, so a second worktree can legitimately land on slot 3. That is the collision guard working, not a bug.
3. Teach the task runner to read the ports
Do not include .env in a Makefile: secret values with special characters
break Make's parser. Grep individual keys instead, defaulting to slot 0:
getport = $(or $(shell grep -E '^$(1)=' .env 2>/dev/null | tail -1 | cut -d= -f2),$(2))
WEBAPP_PORT := $(call getport,WEBAPP_PORT,8000)
POSTGRES_PORT := $(call getport,POSTGRES_PORT,5432)
ports: ## Show this worktree's service URLs
@echo " App: http://localhost:$(WEBAPP_PORT)"
Have the "start" target end by calling ports, so every startup prints the
map. Then hunt down every hard-coded port and container name elsewhere in the
task runner: health-check curls, docker exec <fixed-name> (becomes
docker compose exec -T <service>), and any DB URL pinned to the old port.
grep -rn "localhost:80\|docker exec " Makefile finds most of them.
4. Auto-start on create (worktrunk)
In the project's .config/wt.toml:
[post-start]
platform = """
bash scripts/worktree_ports.sh &&
docker compose up -d &&
<your migrate command> &&
<your fast reference-data seed, if any>
"""
[pre-remove]
docker-down = "bash scripts/worktree_down.sh {{ worktree_path }}"
post-start runs in the background; output goes to
.git/wt/logs/<branch>-<source>-<hook>-<name>.log.
Keep slow seeding out of the hook. Deriving a large fixture set (parsing
spreadsheets, generating hundreds of users) costs minutes of CPU per worktree
and most branches never need it. Put it behind one opt-in command and have the
hook print a hint pointing at it. If you do want data in every worktree, seed
once and pg_restore a dump in the hook instead of re-deriving it; a bulk
restore takes seconds because it skips all the parsing. Re-dump when migrations
or fixtures change, and run migrations after restoring.
Teardown needs its own script, not a bare docker compose down. Copy
scripts/worktree_down.sh from this skill. The naive version is dangerous: by
the time pre-remove runs, the worktree may already have been moved aside, so
compose finds no .env, falls back to the project name in the compose file, and
that is the primary worktree's stack. A down -v then deletes the main
database. The script resolves the project name from the worktree's own .env,
refuses to act when it is missing or matches the primary's, and deletes
root-owned cache directories (left behind by containers running as root against
a bind mount) that otherwise make wt remove fail to remove the folder.
Plain git worktree add users get everything except the hooks: run the script
and the start command by hand.
5. Optional: show the URL in your status line
Read the port straight out of the worktree's .env so each session shows its
own app URL:
root=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null)
port=$(grep -E '^WEBAPP_PORT=' "$root/.env" 2>/dev/null | tail -1 | cut -d= -f2)
[ -n "$port" ] && printf ' | http://localhost:%s' "$port"
Gotchas that cost real time
- A strict settings loader rejects the new
.envkeys. Anything that validates.envagainst a schema and forbids unknown keys (pydantic-settings defaults toextra="forbid") will crash at import once the port block lands. Relax it to ignore unknown keys..envis shared with compose, so it is not an app-only file. Order matters: if the app hot-reloads, writing the block into a checkout whose code lacks that fix crash-loops the running app. Land the loader fix everywhere before writing blocks into other worktrees. - Host-run scripts silently target the ORIGINAL database. This is the
worst one, because nothing fails: seeders, migration wrappers, and one-off
scripts that run on the host (not in a container) usually default to a
hard-coded
localhost:<original-port>. From a second worktree they cheerfully write into the primary worktree's database while reporting success. Fix it by pinningDATABASE_URL(and any other service URL) inside the managed.envblock, pointing at that worktree's published port. Containers are unaffected when compose sets those variables explicitly in each service'senvironment:, which beats.env. Verify withdocker compose config --format json | jq -r '.services.<svc>.environment.DATABASE_URL'and confirm it still shows the internal hostname. Then prove the fix by seeding and checking that row counts moved in the NEW database, not the old one. - TOML folds top-level keys into the preceding table. A bare
post-start = [...]written after[pre-start]silently becomes a key of[pre-start]. Keep top-level keys above the first[table]header, or use a[post-start]table. - worktrunk ignores unknown config keys with a warning, not an error. The
list-form pipeline documented under a
[hooks]table is not read by every version (0.32.0 prints "Unknown key hooks will be ignored"), which silently disables the whole auto-start. After editing hooks, runwt config showand check for "Unknown key". Prefer one&&-chained command: commands in apost-starttable run concurrently, so ordered steps need chaining anyway. .envgets copied between worktrees.wt step copy-ignoredcopies gitignored files, so a new worktree inherits the source worktree's slot and would collide with it. The script guards against this; keep that guard if you rewrite it.- The background pipeline dies with its parent shell. A long
build-migrate-seed chain stops if the terminal goes away. Every step should
be idempotent so
wt hook post-startcan resume it. - Each worktree seeds its own empty database. That is the real cost of
isolation, not the ports. Budget for it, and let
pre-removereclaim the volumes. - Resource use is real. N worktrees means N full stacks. A log viewer that mounts the docker socket only needs to run once, since it already sees every container on the machine.
Verify end to end
- Create a worktree with
wt switch --create <branch>(base it on the branch that has these changes, or its checkout will not have the hooks). docker ps --format '{{.Names}}\t{{.Ports}}': distinct project prefixes, no overlapping host ports.- Curl both worktrees' apps at once; both answer.
- Confirm the new worktree's data is its own, not another worktree's.
wt remove <branch>, then confirm its containers and volumes are gone.
Do not skip step 5, and check it by counting containers and volumes rather than trusting the command's output. A teardown hook that silently no-ops looks identical to one that worked, and the failure only shows up later as leaked ports and disk. Test the refusal path too: point the teardown script at the primary worktree and confirm it declines and leaves that stack running.