---
name: worktree-stacks
description: Use when multiple git worktrees of the same docker compose project collide on host ports, or when a new worktree should come up as a running app automatically. Gives each worktree its own compose project, its own port block, and (with worktrunk) auto-start on create and teardown on remove.
---

# 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:

1. **Host ports** are global to the machine.
2. **`container_name:`** is global to the docker daemon, so a fixed
   `container_name` blocks 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:

```yaml
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:

```bash
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:

```make
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`:

```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:

```bash
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 `.env` keys.** Anything that
  validates `.env` against a schema and forbids unknown keys (pydantic-settings
  defaults to `extra="forbid"`) will crash at import once the port block lands.
  Relax it to ignore unknown keys. `.env` is 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
  pinning `DATABASE_URL` (and any other service URL) inside the managed `.env`
  block, pointing at that worktree's published port. Containers are unaffected
  when compose sets those variables explicitly in each service's
  `environment:`, which beats `.env`. Verify with
  `docker 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, run `wt config show` and
  check for "Unknown key". Prefer one `&&`-chained command: commands in a
  `post-start` table run **concurrently**, so ordered steps need chaining
  anyway.
- **`.env` gets copied between worktrees.** `wt step copy-ignored` copies
  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-start` can 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-remove` reclaim 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

1. 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).
2. `docker ps --format '{{.Names}}\t{{.Ports}}'`: distinct project prefixes, no
   overlapping host ports.
3. Curl both worktrees' apps at once; both answer.
4. Confirm the new worktree's data is its own, not another worktree's.
5. `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.
