Skip to content

Parallel Dev Stacks

Run several fully isolated dev stacks at once - one per git worktree - so parallel work items never collide on containers, ports, databases, workers, or browser cookies. Every work item gets its own worktree regardless of whether it brings up a stack - that rule lives in work/README.md; this doc owns the stack side. scripts/stack.sh is the front door; the main checkout keeps working exactly as before (docker compose up, containers named campuscore_*, ports 8000/5173/9000/...).

Quick reference

git worktree add -b <issue#>-<slug> ../campuscore-<slug> main   # a worktree per work item, branched off main
cd ../campuscore-<slug>

bash scripts/stack.sh up --seed    # isolated stack, data cloned from the main stack
bash scripts/stack.sh status       # registry + this stack's containers
bash scripts/stack.sh url          # this stack's base URL (for curl etc.)
bash scripts/stack.sh down         # tear down: containers, volumes, registry entry

up prints the stack's URLs when it finishes:

Surface URL
App (Django + built SPA) http://cc-<slug>.localhost:<base+0>
Vite dev server (live reload) http://cc-<slug>.localhost:<base+1>
Worker health http://localhost:<base+8>/health

How isolation works

Every stack is a separate Docker Compose project. stack.sh up writes a repo-root .env (compose interpolation: COMPOSE_PROJECT_NAME=cc-<slug>, hostname, a port block) and campuscore_app/.stack.env (Django-side ALLOWED_HOSTS / CSRF_TRUSTED_ORIGINS for the stack hostname), then starts the core services: db, minio, elasticmq, redis, web, worker, web_ui. Compose project-scopes the network and data volumes automatically, each stack builds and runs its own worktree's code (including its own worker), and docker compose exec -T web ... from the worktree targets that stack - so gate.sh, the pre-commit hooks, and build_spa_local.sh all work unmodified.

Hostnames do the browser-side isolation. Chrome resolves *.localhost to 127.0.0.1 natively and cookies are host-only, so a logged-in session on cc-a.localhost never interferes with cc-b.localhost or localhost:8000.

The port block is deterministic: each slug gets an index in <main-checkout>/.stack-registry.json (git-ignored, allocation serialized by a lockdir), and the block is 10000 + 100*index with offsets web +0, vite +1, minio +2, minio console +3, db +4, redis +5, sqs +6, sqs ui +7, worker health +8, keycloak +9, canvas +10, canvas mail +11.

.stack.env also overrides CAMPUSCORE_SUBDOMAIN (blanked) and CUSTOM_DOMAIN_WITH_PROTOCOL (the stack's own URL), so the SP/SSO URLs the app builds for itself are honest per stack instead of claiming the main .env's domain.

Auxiliary services stay with the main stack only: tailwind, dozzle, the docs sites, and localstack. Keycloak is per-stack and opt-in: stack.sh up --sso starts this stack's own Keycloak and seeds both SSO providers (local-sso-testing.md); the container then stays with the stack until down. Canvas LMS is likewise per-stack and opt-in: stack.sh up --canvas (implies --sso) starts a real Canvas wired to the stack's Keycloak, seeds personas and coursework, and configures the CampusCore connector against it (local-canvas-testing.md); budget 3-4 GiB of extra Docker RAM and expect a slow first bootstrap on a cold cache.

Seeding

A fresh stack has an empty database - no searchable content, and the setup wizard gates the app. stack.sh seed (or up --seed) clones the main stack's data volumes (pgdata, minio_data) into the stack, carrying schema, embeddings, MinIO objects, the configured AppConfig, and the superuser - the stack is immediately logged-in-able and searchable, and up applies the worktree's own migrations on top. The seed refuses to run when a source volume is missing or empty, which is what a main checkout still on the pre-PostgreSQL-18 compose file looks like. A main checkout in that state carries its existing data onto the new image with bash scripts/pg-upgrade-local.sh - run that once before seeding, because a bare docker compose up -d would initialise an empty database instead.

Known limitation: a consistent copy of a running Postgres data directory is not possible, so the seed pauses the main stack's db and minio containers for the few seconds the copy takes. Concurrent seeds serialize through the registry lock.

Caveats

  • A worktree that runs a stack must be brought up through stack.sh. Running plain docker compose up in a worktree that has no generated .env falls back to the default project name and collides with the main stack.
  • The SPA bundle is per-worktree. templates/spa/index.html and static/spa/ are git-ignored build artifacts; a fresh worktree serves a 503 "Frontend not built" on the app URL until the bundle is built (up does this unless --no-spa; the Vite URL works regardless).
  • Docker Desktop VM memory is the ceiling. One stack idles around 2 GiB and the db requests 2 GiB of shared memory for HNSW index builds. The default VM allocation (~8 GiB) fits the main stack plus one worktree stack; raise it to ~24 GiB (Docker Desktop -> Settings -> Resources) for main plus 2-3 stacks.
  • Seeds inherit the main stack's embedding state. A clone is a snapshot: if the main stack's corpus or embedding model moves, re-seed (stack.sh seed is idempotent - it replaces the stack's data volumes).

After the PR merges

If a stack was brought up, stack.sh down from the worktree removes its containers, network, volumes, registry entry, and generated env files. Then, from the main checkout, git worktree remove <path> deletes the checkout - this happens for every item, stack or not. The post-merge cleanup step in the implement skill runs this sequence.