# Local Compose Isolation Standard

> **Scope:** any stack that runs `docker compose` locally (Next.js + Neon, .NET)
> **Layer:** 2 (on keyword)
> **Keywords:** docker compose, worktree, ports, container_name, POSTGRES_PORT, COMPOSE_PROJECT_NAME, local dev, e2e stack
> **Load When:** a compose file, a dev-server port, or a worktree stack is being written or debugged

**Verified against:** Docker Compose v2 + morph-spec 8.34 (dedicated stack per worktree). Last-verified: 2026-09-08.

---

## Why this exists

Every feature worktree runs its **own** compose stack — its own project, its own postgres, its own volumes. That only holds if the compose file lets the harness pick the host ports and the project name. A file that hardcodes either one looks isolated and collides the moment a second worktree starts: `container_name` is never prefixed by the project, and a fixed `5432:5432` is one host port for everybody.

The harness gives each worktree a **block of 10 ports** (`.morph/worktree.env`, written by `morph-spec worktree setup|provision`, regenerated by `worktree link --all`). The same values reach `docker compose` through the `verify` e2e node, `morph-spec e2e up`, the `PreToolUse:Bash` hook (which prefixes `npm run dev` / `dotnet run` / `docker compose` with them) and `morph-spec env` for a plain shell.

## Port layout (one contract, everywhere)

| Offset | Variable | Compose mapping |
|---|---|---|
| +0 | `PORT` | `"${PORT:-3000}:3000"` — web / dev server |
| +1 | `API_PORT` (and `ASPNETCORE_URLS=http://localhost:<+1>` on .NET) | `"${API_PORT:-5000}:8080"` |
| +2 | `POSTGRES_PORT` | `"${POSTGRES_PORT:-5432}:5432"` |
| +3 | `REDIS_PORT` | `"${REDIS_PORT:-6379}:6379"` (reserved) |
| +4..+9 | free | project-specific services |

Also exported: `COMPOSE_PROJECT_NAME=morph-{feature}` (isolates container, network and volume names), `MORPH_PORT_BASE`, `MORPH_PORT_RANGE`, `PLAYWRIGHT_BASE_URL=http://localhost:<+0>`.

## Core Rules

- NEVER set `container_name` — Compose already names containers `<project>-<service>-1`, and an explicit name is shared by every worktree
- ALWAYS publish host ports through a variable with a default: `"${PORT:-3000}:3000"`, never `"3000:3000"` nor `published: 3000`
- ALWAYS keep the database name stable (`POSTGRES_DB: ${POSTGRES_DB:-app}`) — the project name already namespaces the container and the volume; baking a feature name into the DB name outlives the feature that scaffolded it
- ALWAYS declare a healthcheck on every service — `docker compose up --wait` (what the e2e node runs) is meaningless without one
- ALWAYS give `DATABASE_URL` an in-network default so the stack comes up with no `.env`: `postgresql://postgres:postgres@postgres:5432/${POSTGRES_DB:-app}`
- ALWAYS read the base URL from the environment in `playwright.config.ts`: `process.env.PLAYWRIGHT_BASE_URL ?? \`http://localhost:${process.env.PORT ?? 3000}\``
- NEVER put a cloud connection string (Neon branch etc.) in `.morph/worktree.env` — it is regenerated; credentials go in the gitignored file named by `config.json → e2e.auth.envFile`
- NEVER put a variable from the port layout table above (nor `PLAYWRIGHT_BASE_URL`) in that credentials file — the harness **ignores** it and says which key it ignored on stderr. Ports come from the allocated block; the file carries credentials. A `.env.e2e` copied from a sibling worktree carries THAT worktree's block, and before the filter existed it silently repointed this stack at the neighbour's postgres

## What the harness enforces

- Inside a secondary worktree, a compose with a fixed host port or a `container_name` **fails the `verify` e2e node** (`compose-not-isolable`) — Gate 3 cannot pass on a stack that cannot run beside its siblings. At the primary root the same finding is a warning.
- `morph-spec finish` tears the stack down **with volumes** (`--keep-volumes` opts out) and deletes the freshness stamp; `morph-spec worktree remove` tears it down without volumes (the feature is still open).
- Two worktrees never share `POSTGRES_PORT`: the block is allocated first-free-index per feature and released only when the worktree is removed.
- The credentials env file is read **after** the block but cannot outrank it. All four surfaces (`e2e up`, the `verify` e2e node, the teardown, `morph-spec env`) build their environment through the same `buildComposeEnv`, which drops the layout keys coming from that file and names them on **stderr** — stdout stays eval-safe for `eval "$(morph-spec env)"`. With no block allocated (outside a worktree) nothing is filtered: there the file is the only port source.

## Minimal compose skeleton

```yaml
services:
  app:
    build: { context: ., dockerfile: Dockerfile.dev }
    ports:
      - "${PORT:-3000}:3000"
    environment:
      - DATABASE_URL=${DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/${POSTGRES_DB:-app}}
    depends_on:
      postgres: { condition: service_healthy }
    healthcheck:
      test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
      interval: 10s
      timeout: 5s
      retries: 12
  postgres:
    image: postgres:15-alpine
    ports:
      - "${POSTGRES_PORT:-5432}:5432"
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: ${POSTGRES_DB:-app}
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d ${POSTGRES_DB:-app}"]
      interval: 5s
      timeout: 5s
      retries: 10
volumes:
  postgres-data:
```

## Running it by hand

```bash
# inside the worktree, outside Claude Code
eval "$(morph-spec env)"        # PowerShell: morph-spec env --powershell | Invoke-Expression
docker compose up -d --wait     # lands in project morph-{feature}, on the block's ports
```
