Efficient Dev: as many local platforms as you need

One command per environment. Every branch, shim, or experiment gets its own full platform — database, auth, web, apps — with zero port juggling.

./multi-stack.sh up a · up b · up c … they all just coexist
Why it exists · How it works · How to use it · What's next: dev-mode
Why

Parallel work is the normal case now

🌿

Many branches in flight

You're mid-feature on one branch, reviewing a PR on another, and reproducing a bug on a third. Each needs a running platform.

🤖

Agents & shims

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.

⚖️

Compare states

"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.

Before

One environment to rule them all — badly

The environment wasn't the problem — the assumption that there's only ever one of it was.

The insight

Docker Compose already isolates everything per project.

  • docker compose -p <name> scopes containers, networks, and volumes to that project name.
  • Publish ports as container-port only and Docker assigns a free host port — no clashes, ever.
  • So: derive a unique project name per branch, let Docker pick the port, and stacks coexist for free.

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.

What

multi-stack.sh — one script, three ideas

🏷️

1 · Name from the branch

The compose project is tdl-<branch-slug>[-<shim>]. Same branch ⇒ same stack, deterministically. Add a shim to run several off one branch.

🎲

2 · Ephemeral ports

An overlay replaces the router's fixed :8080 with an ephemeral binding. Docker picks a free host port; the script discovers and prints it.

🔑

3 · Instantly loginable

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.

What — anatomy

What's inside each stack

platform-router
nginx · the only published port (ephemeral)
routes to
platform-web · app-host · auth
login, dashboard, app SPAs — internal only
backed by
postgres · gcs · migrations
own DB volume, own object store — per stack
How — naming

Deterministic names, optional shims

You're onCommandCompose project (prefix)
feat/receipts./multi-stack.sh uptdl-feat-receipts
feat/receipts./multi-stack.sh up atdl-feat-receipts-a
feat/receipts./multi-stack.sh up btdl-feat-receipts-b
fix/login-loop (another shim)./multi-stack.sh uptdl-fix-login-loop
How — the verbs

The whole API is six verbs

./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.

How — under the hood

What up actually does

1
docker compose -p tdl-<slug> -f docker-compose.yml -f docker-compose.multi.yml up -d — the overlay's ports: !override ["8080"] makes the router's host port ephemeral.
2
Discover the port Docker assigned: docker port <prefix>-platform-router-1 8080 (polls until the router exists).
3
Re-up with the URLs alignedPLATFORM_PUBLIC_URL and APP_HOST_BASE_DOMAIN now point at the discovered port; only the services that read them are recreated.
4
Seed the default super-admin into this stack's postgres (idempotent; skip with MULTI_STACK_NO_SEED=1).
5
Print the URL, login page, and credentials.
How — the subtle bit

Why the two-phase bring-up?

Login redirects need the public URL —
but the port doesn't exist yet.

  • Auth & web bake PLATFORM_PUBLIC_URL into post-login redirects and OAuth origin checks.
  • With ephemeral ports, that URL is only knowable after Docker assigns one.
  • So up boots first, reads the port, then re-applies the env — compose only recreates the containers whose config changed. The router keeps its port.

Get this wrong and login "works" but bounces you to a port that isn't yours — the script exists so nobody debugs that twice.

How — users & login

Each stack is immediately loginable

./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
Using it — day to day

The workflow it unlocks

🌿

Per-branch stack

up on the branch, work, down when merged. Switch branches without destroying anything — each branch's stack (and data) waits for you.

🧪

Before / after

up a on main-ish state, up b with your change. Two browsers, side by side. Demos and regression checks become trivial.

🤖

Agent sandboxes

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.

🧹

Cheap hygiene

down --volumes wipes a stack completely. Stacks are cattle: if one's weird, delete it and up again.

Using it — read this once

Prereqs & sharp edges

Troubleshooting

When a stack misbehaves

⚠️ "What's my URL again?" / URL dead after a Docker restart
Run ./multi-stack.sh url [shim] — or just re-run up: it's idempotent, rediscovers the port, and re-aligns the public URLs.
⚠️ Login bounces back to the sign-in page
You're probably on the wrong port (an old stack's URL). Check url for the stack you mean. Cookies are non-Secure over plain http by design — that part just works.
⚠️ Auto-seed failed during up
The stack still came up. Seed manually: ./multi-stack.sh admin <shim> admin@example.com — it targets that stack's postgres container (<prefix>-local-postgres-1).
⚠️ Which stacks are even running?
docker ps --format '{{.Names}}' | grep '^tdl-' — every multi-stack container is prefixed, so they're easy to spot (and to clean up).
Recap

The TL;DR

Before

One stack, fixed ports, shared DB — parallel work meant collisions, clobbered state, destructive branch switches.

Now

./multi-stack.sh up — isolated, loginable platform per branch / shim / experiment, on a port Docker picks. Your k3d cluster stays untouched.

The trick

Compose project names scope everything; ephemeral ports kill collisions; a two-phase up aligns login redirects to the assigned port.

Remember

Same shim = same stack. down --volumes to wipe. Base stack only (for now). Stacks run frozen images — which brings us to…

Next — dev-mode

The missing piece: hot reload

Today, a stack shows you a commit.
It should show you your keystrokes.

  • Stacks run prebuilt images — edit code, see nothing.
  • Testing a web change: nx build platform-webdocker build → edit .env → force-recreate. Minutes per iteration.
  • The Tilt flow runs web from source, but it's a production build — manual pnpm build + trigger per change, single-stack, no HMR.

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-mode design

Design: host-run dev web, per stack

✓ Chosen: web runs on the host

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.

Alternative: dev server in a container

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.

Next — dev-mode roadmap

Roadmap: four phases

P0
Plumbing shipped
dev overlay (ephemeral backend ports) + multi-stack.sh dev [shim]: discover ports, wire env, hand you a running dev web. Exit: one command gives a working from-source web against any stack.
P1
Web hot reload validated
next dev --turbo + route warm-up. Measured: login flow works end-to-end; edit → Fast Refresh in ~2.4s. The watch→build→restart fallback wasn't needed. Remaining: dogfood on a real feature branch.
P2
Backend fast reload
multi-stack.sh reload <svc> [shim]: rebuild the Go image (NX-cached) and force-recreate just that service in just that stack. Then true watch-mode (air-style) for auth/app-host. Exit: Go change live in <30s.
P3
Whole-stack ergonomics
Ephemeral-port treatment for the services overlay (orchestrator, assistants, kafka) so full stacks run multi; persist a stack's dev-mode across ups; docs + demo.

Questions?

./multi-stack.sh up http://localhost:<yours>

Isolated. Deterministic. As many as you need. Hot reload next.

Efficient Dev — multi-stack local environments
1 / 19
Use to navigate · F fullscreen