One command per environment. Every branch, shim, or experiment gets its own full platform — database, auth, web, apps — with zero port juggling.
You're mid-feature on one branch, reviewing a PR on another, and reproducing a bug on a third. Each needs a running platform.
Coding agents work in parallel shims — fresh clones of the repo, each on its own branch. Each one needs its own stack to build against and verify in — without stepping on yours.
"Does it behave differently before and after my change?" is trivial to answer when you can run both, side by side.
Bottom line: one shared local environment can't serve parallel work.
The environment wasn't the problem — the assumption that there's only ever one of it was.
Docker Compose already isolates everything per project.
multi-stack.sh is ~170 lines of glue around these two Docker features — and a compose stack is light enough to run one per shim, which a k3d cluster never will be.
The compose project is tdl-<branch-slug>[-<shim>]. Same branch ⇒ same stack, deterministically. Add a shim to run several off one branch.
An overlay replaces the router's fixed :8080 with an ephemeral binding. Docker picks a free host port; the script discovers and prints it.
up auto-seeds admin@example.com / password into that stack's own database. Bring it up, log straight in.
Lives at local/multi-stack.sh — wraps docker-compose.yml + docker-compose.multi.yml.
| You're on | Command | Compose project (prefix) |
|---|---|---|
| feat/receipts | ./multi-stack.sh up | tdl-feat-receipts |
| feat/receipts | ./multi-stack.sh up a | tdl-feat-receipts-a |
| feat/receipts | ./multi-stack.sh up b | tdl-feat-receipts-b |
| fix/login-loop (another shim) | ./multi-stack.sh up | tdl-fix-login-loop |
./multi-stack.sh up [shim] # bring up + seed admin + print URL ./multi-stack.sh url [shim] # print the running stack's URL ./multi-stack.sh ps [shim] # docker compose ps for that stack ./multi-stack.sh down [shim] [--volumes] # tear down (--volumes wipes its data) ./multi-stack.sh admin [shim] <email> [name] [pw] # seed a super-admin into that stack ./multi-stack.sh user [shim] <email> [name] [pw] [role] # seed an org user (owner|admin|user)
# a real session: ./multi-stack.sh up a ✓ stack 'tdl-feat-receipts-a' is up URL: http://localhost:55123 login: http://localhost:55123/login admin: admin@example.com / password
Run from local/. Everything takes the same optional shim, so commands always target the stack you mean.
Login redirects need the public URL —
but the port doesn't exist yet.
Get this wrong and login "works" but bounces you to a port that isn't yours — the script exists so nobody debugs that twice.
./multi-stack.sh admin a you@example.com "You" hunter2 # super-admin → stack a only ./multi-stack.sh user b dev@example.com "Dev" hunter2 admin # org admin → stack b only
up on the branch, work, down when merged. Switch branches without destroying anything — each branch's stack (and data) waits for you.
up a on main-ish state, up b with your change. Two browsers, side by side. Demos and regression checks become trivial.
Every shim an agent works in gets its own disposable platform — its own DB to migrate, its own users to seed, nothing shared with yours.
down --volumes wipes a stack completely. Stacks are cattle: if one's weird, delete it and up again.
One stack, fixed ports, shared DB — parallel work meant collisions, clobbered state, destructive branch switches.
./multi-stack.sh up — isolated, loginable platform per branch / shim / experiment, on a port Docker picks. Your k3d cluster stays untouched.
Compose project names scope everything; ephemeral ports kill collisions; a two-phase up aligns login redirects to the assigned port.
Same shim = same stack. down --volumes to wipe. Base stack only (for now). Stacks run frozen images — which brings us to…
Today, a stack shows you a commit.
It should show you your keystrokes.
dev-mode = one stack where the web (then the Go services) live-reload as you type, while other stacks stay frozen as stable baselines.
next dev --turbo natively (fast HMR, existing node_modules), wired to that stack's backends via ephemeral published ports. Proven shape — it's how the Tilt flow already runs the web, made per-stack.
Fully self-contained (down cleans everything) but pays for it: per-stack pnpm install into volumes, macOS bind-mount watch performance, extra memory. Revisit later if containment matters more than speed.
Isolated. Deterministic. As many as you need. Hot reload next.