Run the Packmind local dev stack
One reliable way to bring Packmind up locally, confirm it's serving, and take it back down. Other skills (michel-ui-demo-recorder, michel-cli-demo-recorder) and the Michel worker prompt all defer here instead of carrying their own copy — so the lifecycle stays correct in one place.
What the stack is
Defined by docker-compose.yml at the repo root. Every service runs from the base node:24.18.0-alpine3.23 image with the repo bind-mounted at /packmind — there is no app image to build. Code runs via nx serve/nx dev with polling file-watchers, so source edits hot-reload; you almost never pass --build.
What is reachable from the host — read this before you curl anything
Only these ports are published to the host: the frontend (4200 in OSS, 4201 in proprietary — see below), 443 (nginx), 5432 (postgres), 6379 (redis), 2345 (pgAdmin). The backend and mcp-server containers have no ports: mapping — their ports (3000 and 3001) exist only inside the compose network. curl localhost:3000 always fails from the host. This is the #1 runtime trap — do not fall into it.
Resolve the frontend host port — never hardcode 4200
The host-published frontend port differs by edition: 4200 for OSS, 4201 for proprietary (docker-compose.yml maps 4201:4200 there so a proprietary stack can run beside an OSS one without a clash). The container-internal port is always 4200 — so the Vite proxy, healthchecks, and e2e (frontend:4200) inside the compose network never change. Only the host port does. Ask compose for the real mapping instead of assuming; this is correct for either edition:
PM_WEB="$(docker compose port frontend 4200 | sed 's#.*:##')" # → 4200 (oss) or 4201 (proprietary)
Every host-side curl/URL below uses localhost:$PM_WEB. Resolve PM_WEB once after up -d (the container must exist for port to report the mapping) and reuse it.
You reach the API and MCP from the host through the frontend: the Vite dev server (container port :4200) proxies /api → backend:3000 and /mcp → mcp-server:3001 (see apps/frontend/vite.config.ts). nginx on :443 proxies everything to the frontend, so it works through :443 too.
| Service | How to reach it from the host | Notes |
|---|---|---|
frontend (React/Vite) |
http://localhost:$PM_WEB |
the dev UI ($PM_WEB = 4200 oss / 4201 proprietary) |
backend (NestJS API) |
http://localhost:$PM_WEB/api/v0 (Vite proxy) — never :3000 |
container-internal port is 3000; API base path is /api/v0 |
mcp-server |
http://localhost:$PM_WEB/mcp (Vite proxy) — never :3001 |
container-internal port is 3001 |
nginx (HTTPS, self-signed) |
https://localhost:443 | TLS front for the frontend; /api and /mcp work through it too |
postgres |
localhost:5432 |
postgres / postgres, db packmind |
redis |
localhost:6379 |
BullMQ + cache |
pgadmin (dev profile) |
http://localhost:2345 | admin@pgadmin.com / password |
In short: from the host, hit the API at localhost:$PM_WEB/api/v0, not localhost:3000. Use localhost:3000 / backend:3000 only from inside a container on the compose network.
Two init services run once on each up and then exit — the long-running services wait on them:
install-dependencies— runsnpm installinto thedev-node_modulesvolume. Slow on first boot, skipped on later boots ifpackage-lock.jsonis unchanged.run-migrations— runs TypeORM migrations against Postgres.backendandmcp-serverwill not start until this completes successfully.
nx-daemon (in the dev profile) speeds up rebuilds. The app services depend on it with required: false, so they run with or without it — just slower without.
The edition variable
PACKMIND_EDITION is resolved from the git remote — oss for the OSS repo, proprietary for packmind-proprietary (which is OSS + extra packages). Do not hardcode it: export the resolved value once and every compose command (and the matching teardown) inherits it.
export PACKMIND_EDITION="$(bash scripts/michel/resolve-edition.sh)"
The base compose file defaults to oss when unset, but the project/container names embed the edition — so up and down MUST use the same value, and the proprietary repo MUST come up as proprietary or its edition-gated packages resolve to OSS stubs.
Bringing it up
Use the dev profile so the nx-daemon (faster rebuilds) and pgAdmin come up too:
docker compose --profile dev up -d # background — the usual choice for agents (PACKMIND_EDITION already exported)
docker compose --profile dev up # foreground — logs stream, Ctrl-C stops
Plain docker compose up -d (no profile) also works — it skips nx-daemon and pgAdmin and the app services fall back to daemonless serve.
First boot is slow. install-dependencies does a full npm install, then migrations run, then nx cold-builds the API and frontend. Watch progress:
docker compose logs -f backend frontend
up -d returns before anything is serving. Always poll readiness before depending on the stack — and poll the API through the frontend proxy (host port $PM_WEB), never :3000 (not host-exposed):
PM_WEB="$(docker compose port frontend 4200 | sed 's#.*:##')" # 4200 oss / 4201 proprietary
until curl -sf "localhost:$PM_WEB" >/dev/null; do sleep 1; done # frontend ready
until curl -sf "localhost:$PM_WEB/api/v0" >/dev/null; do sleep 1; done # API ready (Vite proxy → backend:3000)
Connection-refused = not up yet (still installing/migrating/building). Give first boot several minutes.
How to wait, for autonomous agents — this has lost real runs:
- A long bare
sleep(e.g.sleep 30 && curl …) is blocked by the agent harness. Wait with a condition-gated loop instead — a shortsleepinside anuntilloop is allowed:until curl -sf "localhost:$PM_WEB" >/dev/null; do sleep 2; done. - Run that readiness loop in the foreground and stay in your turn until it completes. If you run it as a background task and then end your turn "waiting to be notified", a one-shot headless session (e.g. a Michel run via
claude --print) terminates at your final message — the notification never arrives, and whatever you postponed until "the stack is ready" (screenshots, verification, teardown) silently never happens. A real run shipped a PR with zero of its required screenshots exactly this way. Slow cold build = keep looping, not yield.
Frontend troubleshooting (two real frictions)
The frontend is the flakiest service on first boot. Two failure modes seen in practice:
frontendexits withFailed to reconnect to daemon after multiple attempts(status 1). Thenx-daemonsocket dropped and thefrontend:devtask — being "continuous" — died with it, so the container leaves thepslist andcurl localhost:$PM_WEBrefuses. The other services stay up. Check withdocker compose ps -a | grep front(look forExited (1)), then just restart it:docker compose --profile dev up -d frontend until curl -sf "localhost:$PM_WEB" >/dev/null; do sleep 2; donePage stuck on "Loading Packmind…", console flooded with
net::ERR_NETWORK_CHANGED. On a cold Vite start the dev server hands the browser hundreds of unbundled ESM module requests; a single network blip mid-load aborts the batch and the SPA never finishes booting. It is transient and not a code error. Just reload the page once Vite has finished optimizing (docker compose logs frontendshows[optimizer] bundling dependencies...→ done). A hard reload / re-navigate clears it.
When a native addon won't load (libc) — reset, don't dissect
A run-migrations/backend crash citing @swc/core, an nx native binding, a
*.node file, GLIBC_, Error relocating, or musl is a dependency/libc
problem, not a code bug — and it has a one-line fix. Do not go spelunking
in the binaries; a real run once burned ~90 minutes doing exactly that.
docker compose --profile dev down -v && docker compose --profile dev up -d
- Reset first.
down -vdrops thedev-node_modulesvolume; the nextupre-installs every native addon for the running container's libc. This is the fix. - Never
readelf/ldd/od/grepbinding.jsto "diagnose" the.nodefile. It tells you nothing actionable here. - Never hand-roll
docker runto replace a compose service. Under the Michel override the stack runs on glibc (node:24.18.0-trixie-slim); a manualnode:*-alpine(musl) container is the wrong libc and fails to load every native addon — that error is self-inflicted, not the stack's. If you truly need a one-off, usenode:24.18.0-trixie-slimwith the same volumes/env as compose. - Never edit application source to make the stack boot. A DI error / missing provider that shows up only under a hand-rolled or half-started boot is an artifact of the wrong boot path — the reset above makes it disappear. Don't "fix" it in code.
Re-running migrations
When you add or change a migration, re-run just that init service against the running Postgres:
docker compose up run-migrations
It runs the new migrations and exits. Restart backend/mcp-server if they need the new schema.
Starting from a clean database
The dev-postgres-data volume persists across down, so a prior run — including an earlier Michel run on the same issue — leaves rows and applied-migration state behind. The API then returns confusing data during verification even though the current code is correct. For a known-clean state (recording a demo, verifying a schema change, reproducing from scratch), wipe volumes and bring it back up:
docker compose --profile dev down -v
docker compose --profile dev up -d
-v drops all dev volumes — dev-postgres-data, dev-redis-data, dev-node_modules, dev-dist, dev-tmp, dev-nx-sock, dev-pgadmin. The next up re-installs dependencies and re-runs every migration from scratch, so it's a full first-boot again (slow). Seed data via the API (POST to /api/v0/...) after the stack is up — don't record or verify over leftover state.
Plain docker compose --profile dev down (no -v) is correct when you want existing data — e.g. resuming work where you left off, or avoiding a slow re-install.
Creating the first account — mind the password policy
A fresh instance has no account; the first user and its organization are created by signing up — through the UI (/sign-up) or by POSTing the signup endpoint. Both paths run the same server-side password check, and that check is the #1 reason an auth-setup script fails on its first run: a too-weak password is rejected with a raw error, not a friendly hint, so the script looks like it "silently" did nothing.
The password must be:
- at least 8 characters, AND
- at least 2 non-alphanumeric characters — anything outside
a-z A-Z 0-9(!,#,@,.,-, …).
Enforced in SignUpWithOrganizationUseCase.validatePassword(); violations throw Password must be at least 8 characters or Password must contain at least 2 non-alphanumerical characters. So Password1 (zero non-alphanumeric chars) is rejected; Packmind!Demo#2026 (two non-alphanumeric chars, 18 long) passes.
Create the account + org in one call from a script — the signup endpoint is public and the org name is derived from the email:
curl -s -X POST "localhost:$PM_WEB/api/v0/auth/signup" \
-H 'Content-Type: application/json' \
-d '{"email":"michel@packmind-demo.com","password":"Packmind!Demo#2026","method":"password"}'
Driving the full UI sign-up flow (org name, onboarding reason, welcome dialog) is covered by michel-create-packmind-dataset §2.
Building the CLI (when you need the binary, not the server)
The API and frontend need no build step — they serve from source inside the containers. The CLI does:
npm run packmind-cli:build # → dist/apps/cli/main.cjs
Run the built CLI with node ./dist/apps/cli/main.cjs (per the project CLAUDE.md). Build it before demoing it — a demo of stale dist/ proves nothing about your change. The CLI talks to a Packmind API, so start the stack first if you want it to hit your local backend.
Taking it down
docker compose --profile dev down # stop + remove containers; volumes PRESERVED
docker compose --profile dev down -v # ALSO drop all dev volumes (data + schema + node_modules)
Pass --profile dev so the nx-daemon and pgAdmin containers are removed too.
If you started the stack, tear it down before you finish — this is not optional for autonomous agents. A lingering compose stack (and any host-side nx serve, or open Playwright/chrome-devtools MCP browser session) leaves containers running that block the run from completing. docker compose --profile dev down after your verification/recording is the close-out step, every time.
Use down (volumes preserved) by default. Reach for down -v only when you specifically want the next up to start from an empty database and a fresh install.
Quick reference
| Goal | Command |
|---|---|
| Resolve + export the edition | export PACKMIND_EDITION="$(bash scripts/michel/resolve-edition.sh)" |
| Start in background | docker compose --profile dev up -d |
| Watch boot logs | docker compose logs -f backend frontend |
| Resolve the frontend host port | PM_WEB="$(docker compose port frontend 4200 | sed 's#.*:##')" (4200 oss / 4201 proprietary) |
| Confirm frontend serving | until curl -sf "localhost:$PM_WEB" >/dev/null; do sleep 1; done |
| Confirm API serving (via proxy) | until curl -sf "localhost:$PM_WEB/api/v0" >/dev/null; do sleep 1; done |
| Re-run migrations | docker compose up run-migrations |
| Build the CLI | npm run packmind-cli:build |
| Stop (keep data) | docker compose --profile dev down |
| Stop + wipe all volumes | docker compose --profile dev down -v |
Gotchas, condensed
export PACKMIND_EDITION="$(bash scripts/michel/resolve-edition.sh)"— resolve it from the git remote once, before any compose command. Never hardcodeoss; the proprietary repo must come up asproprietary, andup/downmust agree.- Frontend host port is edition-dependent — never hardcode
4200.4200(OSS) vs4201(proprietary); container-internal is always4200. Resolve it once:PM_WEB="$(docker compose port frontend 4200 | sed 's#.*:##')", then uselocalhost:$PM_WEB. A run probing:4200on proprietary waits on a port nothing serves. - Port 3000 is NOT exposed to the host.
curl localhost:3000always refuses the connection — thebackendcontainer has noports:mapping. From the host, reach the API atlocalhost:$PM_WEB/api/v0(Vite proxy) or via nginxhttps://localhost:443. Likewise the MCP server is only atlocalhost:$PM_WEB/mcp, neverlocalhost:3001. Use:3000/:3001only from inside a container on the compose network. up -d≠ ready. Poll:$PM_WEB(frontend) and:$PM_WEB/api/v0(API via proxy) before depending on the stack. First boot takes minutes (install + migrate + cold build).- Wait in the foreground, inside your turn. Bare long
sleeps are harness-blocked; useuntil curl -sf …; do sleep 2; doneand stay in the loop until it exits. Never end your turn expecting a background readiness task to wake you — in a one-shot headless session it won't, and everything you postponed is lost. See "How to wait" above. - No app image build. Source is bind-mounted and hot-reloads;
--buildis almost never needed. Don't reach for it the way you would on an image-based stack. dev-postgres-dataoutlivesdown. Stale rows and applied-migration state from a prior run cause phantom data during verification.down -vfor a true clean slate (and a slow re-boot).dev-node_modulesis a volume too. Dependency changes are picked up by re-runninginstall-dependencies(re-up); adown -vforces a full reinstall.- MCP server has no host port. It's reachable only from inside the compose network (e.g. by the frontend), not from your host via
localhost. - Frontend can die on its own after a clean boot.
Failed to reconnect to daemonkills the continuousfrontend:devtask → containerExited (1),localhost:$PM_WEBrefuses. Restart just that service:docker compose --profile dev up -d frontend. - "Loading Packmind…" forever +
ERR_NETWORK_CHANGEDspam = transient cold-Vite hiccup, not a bug. Reload the page after the optimizer finishes bundling. Don't go debugging the app. - Native-addon / libc errors (
@swc/core,nxnative,*.node,GLIBC_,musl) → reset, don't dissect.docker compose --profile dev down -v && docker compose --profile dev up -d. The stack is glibc (trixie-slim); never reproduce in a manualnode:*-alpinecontainer (wrong libc), and never edit app code to make it boot. See "When a native addon won't load" above. - The API base is
/api/v0, not/api. Health check and all calls hang off that prefix. - Sign-up password policy is enforced server-side. The signup API rejects any password under 8 chars or with fewer than 2 non-alphanumeric chars — with a raw error, not a hint, so a weak password looks like a silent failure. Use one like
Packmind!Demo#2026. See "Creating the first account". - Never leave it running. If you brought it up,
docker compose --profile dev downbefore finishing — lingering containers block completion.