The canonical recipe for starting, checking, and stopping the Packmind local dev stack with Docker Compose — the single source of truth other skills and the Michel agent defer to. Covers bringing the full stack (PostgreSQL, Redis, NestJS API, React/Vite frontend on :4200, MCP server, nginx) up in the background, the init services (dependency install + TypeORM migrations) you must wait on, the critical host-port trap that the API on container port 3000 is NOT exposed to the host and must be re...
Installs into .claude/skills of the current project.
Are you the author of Michel Run Local Dev Stack?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/packmindhub-michel-run-local-dev-stack)
---
name: michel-run-local-dev-stack
description: The canonical recipe for starting, checking, and stopping the Packmind local dev stack with Docker Compose — the single source of truth other skills and the Michel agent defer to. Covers bringing the full stack (PostgreSQL, Redis, NestJS API, React/Vite frontend on :4200, MCP server, nginx) up in the background, the init services (dependency install + TypeORM migrations) you must wait on, the critical host-port trap that the API on container port 3000 is NOT exposed to the host and must be reached via the frontend Vite proxy at localhost:4200/api/v0, confirming the API and frontend are actually serving before you depend on them, the persistent-volume gotcha that leaves stale Postgres schema and node_modules behind between runs, building the CLI, and tearing everything down so no container is left blocking the run. Use this whenever you need Packmind running locally — to verify a change, record a UI or CLI demo, hit the API, seed data, or reproduce a bug — and whenever you are about to start or stop `docker compose`. If you are an autonomous agent (e.g. Michel) that started the stack, you MUST use the teardown half before finishing. Prefer this over running `nx serve` on the host for anything that needs the real, containerized stack.
---
# 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:
```bash
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` — runs `npm install` into the `dev-node_modules` volume. Slow on first boot, skipped on later boots if `package-lock.json` is unchanged.
- `run-migrations` — runs TypeORM migrations against Postgres. `backend` and `mcp-server` will 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.
```bash
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:
```bash
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:
```bash
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):
```bash
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 short `sleep` _inside_ an `until` loop 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:
- **`frontend` exits with `Failed to reconnect to daemon after multiple attempts` (status 1).**
The `nx-daemon` socket dropped and the `frontend:dev` task — being "continuous" — died with it,
so the container leaves the `ps` list and `curl localhost:$PM_WEB` refuses. The other services stay
up. Check with `docker compose ps -a | grep front` (look for `Exited (1)`), then just restart it:
```bash
docker compose --profile dev up -d frontend
until curl -sf "localhost:$PM_WEB" >/dev/null; do sleep 2; done
```
- **Page 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 frontend` shows `[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.
```bash
docker compose --profile dev down -v && docker compose --profile dev up -d
```
- **Reset first.** `down -v` drops the `dev-node_modules` volume; the next `up`
re-installs every native addon for the running container's libc. This is the fix.
- **Never `readelf`/`ldd`/`od`/grep `binding.js`** to "diagnose" the `.node` file. It
tells you nothing actionable here.
- **Never hand-roll `docker run` to replace a compose service.** Under the Michel
override the stack runs on **glibc** (`node:24.18.0-trixie-slim`); a manual
`node:*-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, use `node:24.18.0-trixie-slim` with 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:
```bash
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:
```bash
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 `POST`ing 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:
```bash
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:
```bash
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
```bash
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 hardcode `oss`; the proprietary repo must come up as `proprietary`, and `up`/`down` must agree.
- **Frontend host port is edition-dependent — never hardcode `4200`.** `4200` (OSS) vs **`4201` (proprietary)**; container-internal is always `4200`. Resolve it once: `PM_WEB="$(docker compose port frontend 4200 | sed 's#.*:##')"`, then use `localhost:$PM_WEB`. A run probing `:4200` on proprietary waits on a port nothing serves.
- **Port 3000 is NOT exposed to the host.** `curl localhost:3000` always refuses the connection — the `backend` container has no `ports:` mapping. From the host, reach the API at **`localhost:$PM_WEB/api/v0`** (Vite proxy) or via nginx `https://localhost:443`. Likewise the MCP server is only at `localhost:$PM_WEB/mcp`, never `localhost:3001`. Use `:3000`/`:3001` only 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 `sleep`s are harness-blocked; use `until curl -sf …; do sleep 2; done` and 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; `--build` is almost never needed. Don't reach for it the way you would on an image-based stack.
- **`dev-postgres-data` outlives `down`.** Stale rows and applied-migration state from a prior run cause phantom data during verification. `down -v` for a true clean slate (and a slow re-boot).
- **`dev-node_modules` is a volume too.** Dependency changes are picked up by re-running `install-dependencies` (re-`up`); a `down -v` forces 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 daemon` kills the continuous `frontend:dev` task → container `Exited (1)`, `localhost:$PM_WEB` refuses. Restart just that service: `docker compose --profile dev up -d frontend`.
- **"Loading Packmind…" forever + `ERR_NETWORK_CHANGED` spam = 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`, `nx` native, `*.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 manual `node:*-alpine` container (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 down` before finishing — lingering containers block completion.