Source: ddx-documentation/setup.md
How to run the workspace on one machine. Local only: a deploy runs on operator request, from the main session, through `./ddx-manage.sh remote <verb> --target <id>` (runbook: [deploy/README.md](/en/docs/deploy/README)). Every fact on this page comes from a repo file named next to it. ## 1. Prerequisites | Tool | Pin | Source | |---|---|---| | Node.js | `.node-version` (`26.12`); the pin table is [versions.md](/en/docs/versions) | `.node-version`, `versions.md` | | pnpm | `pnpm@12.10.1` | root `package.json` → `packageManager` | | Docker + Docker Compose | any current release | `ddx-manage.sh` (`docker compose`) | | `jq`, `curl`, `lsof` | any | `ddx-manage.sh` helpers (`has_script`, `status`, `port_in_use`) | ## 2. Install From the project root: ```sh pnpm install ``` The root `package.json` only fans out (`pnpm -r --if-present lint|typecheck`). Each app keeps its own scripts in `ddx-web/package.json` and `ddx-api/package.json`. ## 3. Environment files Machine facts (host, ports, mode) live in `context/ENVIRONMENT.md`; a fresh clone creates it with `cp context/ENVIRONMENT.template.md context/ENVIRONMENT.md`. Copy each `.env.example` to `.env` in the same folder and fill it. This page names the files and their keys only. It never shows a value. | File | Keys (names only) | |---|---| | `ddx-docker-postgres/.env` | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | | `ddx-docker-minio/.env` | `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD` (the container keys; ddx-api reads the same values as `DDX_API_MINIO_ROOT_*`) | | `ddx-docker-redis/` | none (no credential, no `.env`; `ddx-docker-redis/docker-compose.yml`) | | `ddx-api/.env` | runtime (`NODE_ENV`, `PORT`, `DDX_API_HOST`), logging (`LOG_LEVEL`, `LOG_FORMAT`, `LOG_COLOR`, `LOG_SCOPES`, `LOG_QUEUE_MAX`, `LOG_FLUSH_MS`, `LOG_HTTP_HEADERS`, `DDX_API_LOG_WIDTH`, `DDX_API_LOG_COLOR`, `DDX_API_LOG_CLEAR`; [observability.md](/en/docs/observability)), auth (`BETTER_AUTH_*`, `DDX_API_AUTH_*`, `DDX_API_SYSTEM_ADMIN_*`), Postgres (`DDX_API_POSTGRES_SUPERUSER`, `DDX_API_POSTGRES_SUPERPASSWORD`), six `DATABASE_URL_*` (`AUTH`, `MAIN`, `AUDIT`, `LOGS`, `AI`, `COMMUNICATION`), `DDX_API_SEED_USER_PASSWORD`, limits (`DDX_API_THROTTLE_LIMIT`, `DDX_API_HTTP_BODY_LIMIT_MB`, `DDX_API_HTTP_REQUEST_TIMEOUT_MS`), `DDX_API_SWAGGER_ENABLED`, `DDX_API_CORS_ORIGINS`, SSE bus (`REDIS_URL`, `DDX_API_SSE_MAX_STREAMS_PER_PRINCIPAL`, `DDX_API_SSE_MAX_STREAMS_TOTAL`), webhooks (`DDX_API_WEBHOOK_SOURCES`, `DDX_API_WEBHOOK_SOURCE_<NAME>_{SECRET,EVENTS,ORGS}`, `DDX_API_WEBHOOK_NONCE_TTL_S`, `DDX_API_WEBHOOK_RATE_PER_MIN`, `DDX_API_WEBHOOK_RATE_BURST`, `DDX_API_WEBHOOK_SECRET_KEY`), object store (`DDX_API_MINIO_ENDPOINT`, `DDX_API_MINIO_REGION`, `DDX_API_MINIO_BUCKET_AVATARS`, `DDX_API_MINIO_ROOT_*`), cluster (`DDX_CLUSTER_ID`, `DDX_API_NODE_ID`, `DDX_API_NODE_BASE_URL`, `DDX_API_NODE_KEY_PATH`, `DDX_CLUSTER_ADMIT_MODE`, `DDX_CLUSTER_NODE_AUTH`) | | `ddx-web/.env` (dev values in `.env.local`) | BFF node pool (`DDX_BFF_POOL_SOURCE`, `DDX_BFF_POOL_STATIC_NODES`; server-only, the BFF answers 503 with an empty pool), cluster (`DDX_CLUSTER_ID`, `DDX_WEB_NODE_ID`, `DDX_WEB_NODE_BASE_URL`, `DDX_WEB_NODE_KEY_PATH`), `DDX_BFF_TRUSTED_CLIENT_IP_HEADER` (server-only), `DDX_WEB_GATEWAY_API_KEY`, the same shared `LOG_*` block as ddx-api (`LOG_LEVEL`, `LOG_FORMAT`, `LOG_COLOR`, `LOG_SCOPES`, `LOG_QUEUE_MAX`, `LOG_FLUSH_MS`, `DDX_WEB_LOG_WIDTH`; [observability.md](/en/docs/observability) §3), `NEXT_PUBLIC_APP_URL`, `NEXT_PUBLIC_APP_VERSION`, `NEXT_PUBLIC_DEFAULT_LOCALE`, `NEXT_PUBLIC_UI_DEBUG` (the one UI debug flag, [debug-overlay.md](/en/docs/debug-overlay)), `NEXT_PUBLIC_ENABLE_ANALYTICS` | Rules: - `./ddx-manage.sh` stops with an error when a docker folder has no `.env` (`compose()` in `ddx-manage.sh`). - The BFF node pool (`DDX_BFF_POOL_*`) is server-only. The browser never gets the ddx-api address; it calls the ddx-web BFF `api/v1` ([ports.md](/en/docs/ports) §Rules). - ddx-web has no `BETTER_AUTH_*` and no `DATABASE_URL` ([ADR 0005](/en/docs/adr/0005-frameworks)). ddx-api needs `DDX_API_AUTH_TRUSTED_ORIGINS` to list the web origin, or sign-in fails with `INVALID_ORIGIN` (403). Set `DDX_BFF_TRUSTED_CLIENT_IP_HEADER` and keep ddx-api `DDX_API_HOST` on 127.0.0.1 (or strip that header at the edge); without a trusted IP header the sign-in cap is the per-account bucket only. - `NODE_ENV` (`development` | `production` | `test`) is REQUIRED at ddx-api start (`validate-env.ts`); a missing value stops the boot. - `REDIS_URL` is REQUIRED (value shape: `redis://<host>:16340`). There is no in-memory fallback: ddx-api stops the boot without it, and `./ddx-manage.sh start` stops when the Redis container is not healthy. Redis is reached only by ddx-api ([ADR 0016](/en/docs/adr/0016-sse-bus)). - `DDX_API_WEBHOOK_SECRET_KEY` is REQUIRED: base64 of exactly 32 bytes (`openssl rand -base64 32`); it encrypts outbound-webhook secrets at rest. Do not reuse `BETTER_AUTH_SECRET`. - Inbound webhooks are opt-in: an empty `DDX_API_WEBHOOK_SOURCES` makes every source answer 404. Each listed source needs `DDX_API_WEBHOOK_SOURCE_<NAME>_SECRET` (≥ 32 chars), `_EVENTS` (subset of the inbound event types) and `_ORGS` (org ids; `*` is refused when `NODE_ENV=production`). `DDX_API_WEBHOOK_NONCE_TTL_S` must exceed 600. Protocol: [sse-events.md](/en/docs/sse-events) §Webhooks. - `DDX_API_SEED_USER_PASSWORD` is the one dev-only password of every seeded user (`ddx-api/prisma/seed/seed-auth.ts`). The seed fails when it is not set. ## 4. Lifecycle — `./ddx-manage.sh` `./ddx-manage.sh` is the ONE lifecycle owner (invariant 16). Never start a server another way while a managed one holds the port. Dev servers run in the background, each in its own process group; pid + pgid files live in `.ddx-run/`, logs in `logs/` (previous run kept as `logs/<app>.prev.log`). | Verb | What it does | |---|---| | `start [web\|api\|docker\|all] [--clean\|--keep-cache] [--no-wait]` | Docker up (Postgres, Redis, MinIO, `minio-init` buckets), then ddx-api and ddx-web in the background. ddx-web keeps `.next` (dev fs cache) and wipes it only when it is over 2048 MB or with `--clean`; `--keep-cache` never wipes | | `stop [target] [--docker]` | Stops only this repo's processes (stored pid tree, pgid, and repo-owned orphans on the app port); `--docker` also stops Docker | | `restart [target] [--clean\|--keep-cache] [--no-wait]` | `stop` then `start` with the same cache rule | | `status` | State, pid, port, health, RSS, uptime, Docker, disk, URLs; exits 1 when ddx-api lost its build (`MODULE_NOT_FOUND`) | | `urls` · `mem [web\|api\|all]` · `clean` · `pids` | Service URLs · RAM (process-tree RSS) + disk, CSV in `logs/` · wipe `.next` + `node_modules/.cache` (ddx-web stopped) · dry run of what `stop` would signal | | `logs [web\|api\|api2\|docker\|mem\|all] [-n N] [--no-follow] [--raw] [--level L] [--scope P] [--req ID]` | Tails the app logs or the compose logs; `web` folds Next "Import traces:" blocks into one dim line, `--raw` prints the file as is; `--level` is a minimum level, `--scope` a scope prefix, `--req` the first 8 chars of a request id (web, api, api2) | | `logs req <id>` | One request across web, api and api2 in time order, tagged `[web]` `[api]` `[api2]`; never follows | | `preboot [api\|web]` | Pre-boot gate (no target = both): env keys, `LOG_*` value gate; ddx-api also Prisma clients and migration check. `start` and `restart` of either app run it first; a bad value stops the start | | `i18n [--watch]` | i18n parity audit of `ddx-web/messages/{en,fr,de,es}`; `--watch` re-runs `scripts/i18n-check.mjs` on change (`pnpm i18n:check` / `pnpm i18n:watch` in `ddx-web` run the same script) | | `lint` | oxlint (web, api) + i18n parity audit + repo `rg` gates, incl. "no `max-w-`/`min-w-`/`basis-` with `xs..xl`" (brand spacing names collide with Tailwind sizes: `max-w-md` = 16 px; use `max-w-112`) | | `sse [seconds]` | `curl -N` on the BFF event stream `/api/v1/events` (default 10 s) | | `docker <up\|down\|ps\|logs>` | Docker infra only | | `test <web\|api> <spec...>` | Runs only the named specs — no full-suite run | | `docs` | Writes `api/openapi.json` (`ddx-api` `openapi:export`) and `inventory.md` (`ddx-web` `inventory:docs`) into this folder | Source: `usage()` and `main()` in `ddx-manage.sh`. Dev cache: Next.js is 16.4.3. `ddx-web/next.config.ts` leaves the fs dev cache at its default (ON) and sets `experimental.turbopackGc`, `turbopackLazyDynamicImports` and `turbopackRustReactCompiler` to `true`. The 2026-10-23 trial of the cache was reverted for broken HMR, a 2.2 GB `.next` in ~1 h and a stale route module; the 2026-10-24 re-trial and its revert rule (set `turbopackFileSystemCacheForDev: false` on `conflicting effects for the same key` or a stale module) are in `research/rq11-dev-latency.md` §16.4.3 re-trial. The 2048 MB auto-wipe in `ddx-manage.sh` guards the old failure (`.next/dev/cache/turbopack` at 4-8 GB, `/en` > 55 s). Cold-compile probe (run right after `./ddx-manage.sh restart web --clean`): `sh scripts/dev-latency-probe.sh <label>` (login, sign-in, projects, register compile times, RSS, `.next` size). ddx-web logs come from one logger (`ddx-web/src/lib/logger/logger.ts`) in the same pretty layout as ddx-api: one annotated line per record, colour from `chalk` 6 (256 colours; the dev log file is a pipe, so `LOG_COLOR=auto` forces colour outside production; `NO_COLOR` turns it off). `LOG_FORMAT=json` or production writes one JSON line; `LOG_LEVEL` gates the level. Layout, `LOG_*` keys, queue and log access: [observability.md](/en/docs/observability). Scopes today: `bff`, `sse-proxy`, `next`. UI validation is done in jev-browser (owner rule), not with curl loops or headless sweeps. ### Docker network Every docker tier joins ONE bridge network, `ddx-web-boot`, created once by `ensure_network()` on a pinned subnet (default `10.163.0.0/24`, override with `DDX_NETWORK_SUBNET`). If the network exists on another subnet, the script stops and tells you to remove it first. Volumes are not touched. ## 5. Ports The port map lives in ONE place: [ports.md](/en/docs/ports). Summary: web 16300, api 16310, Postgres 16330, Redis 16340, MinIO 16350 / 16351, docs 16370 reserved and unused (the docs render inside ddx-web at `/[locale]/docs`). ## 6. Databases and seed One Postgres instance holds six databases, one per Prisma client ([ADR 0004](/en/docs/adr/0004-database-split)): auth, main, audit, logs, ai, communication. | Step | Command (in `ddx-api/`) | |---|---| | Generate the six clients | `pnpm prisma:generate` | | Apply migrations | `pnpm prisma:migrate` | | Seed all databases | `pnpm db:seed` | | Seed one database | `pnpm db:seed:<auth\|main\|audit\|ai\|communication>` | The seed is idempotent (upserts with deterministic ids — `ddx-api/prisma/seed/data.ts`). ## 7. Seeded logins Source: `ddx-api/prisma/seed/data.ts` (`ORGS`, `ROLES`, `USERS`). Every org gets one user per role. The email is `<role>@<org-slug>.test`. The password is the value of `DDX_API_SEED_USER_PASSWORD` in `ddx-api/.env`. | Role | Acme Health (`acme-health`) | Globex Care (`globex-care`) | |---|---|---| | `super-admin` | `super-admin@acme-health.test` | `super-admin@globex-care.test` | | `org-admin` | `org-admin@acme-health.test` | `org-admin@globex-care.test` | | `staff` | `staff@acme-health.test` | `staff@globex-care.test` | | `member` | `member@acme-health.test` | `member@globex-care.test` | | `client` | `client@acme-health.test` | `client@globex-care.test` | `super-admin` is a platform role: that user gets `platformRole = 'super-admin'` and joins its org as `org-admin` (`ddx-api/prisma/seed/seed-auth.ts`); in any org it acts as org-admin of that org ([ADR 0013](/en/docs/adr/0013-super-admin-platform-role)). Sign in at `/[locale]/auth/login`. Login, register, OTP and select-org then land on `/[locale]/portal`, which resolves the org (session's active org, else the only one, else `/auth/select-org`) and the member role and redirects to `/[locale]/portal/[orgSlug]/[role]` ([ADR 0003](/en/docs/adr/0003-route-shape)). The portal nav has 13 items, shown per role from `ddx-web/src/lib/navigation/portal-registry.ts`. ## 8. Media MinIO is reached ONLY by ddx-api. Browser and mobile media go through the ddx-web BFF proxy (`api/v1`) to ddx-api. There is no presigned URL. - ddx-api routes: `POST /orgs/:orgSlug/media` (raw body), `GET /orgs/:orgSlug/media` (list: `page`, `limit` ≤ 100, `kind`), `GET …/media/:id`, `GET …/media/:id/content` (Range → 206). Org members: `GET /orgs/:orgSlug/members` (name + image only). Spec: `api/openapi.json`. - `pnpm db:seed` needs MinIO up (it uploads the demo PDF and PNG). - The browser camera and microphone need `Permissions-Policy: camera=(self), microphone=(self)` (`ddx-web/next.config.ts`). [ADR 0011](/en/docs/adr/0011-media-proxy) supersedes [ADR 0010](/en/docs/adr/0010-media-path). ## 9. Where to look next - [patterns.md](/en/docs/patterns) — pattern P1-P22 → inventory id → gallery link → file. - [shell-tree.md](/en/docs/shell-tree) — SiteShell / PortalShell tree and provider cascade. - [inventory.md](/en/docs/inventory) — generated component reference (`./ddx-manage.sh docs`). - [sse-events.md](/en/docs/sse-events) — SSE event catalog, channels, webhooks; [ADR 0016](/en/docs/adr/0016-sse-bus). - `api/openapi.json` — generated ddx-api OpenAPI document (`./ddx-manage.sh docs`). --- Last reconciled: 2026-10-25 · plan harness-alignment task 038
How to run the workspace on one machine. Local only: a deploy runs on operator request, from the main session, through
./ddx-manage.sh remote <verb> --target <id> (runbook: ).
Every fact on this page comes from a repo file named next to it.
From the project root:
The root package.json only fans out (pnpm -r --if-present lint|typecheck). Each app keeps
its own scripts in ddx-web/package.json and ddx-api/package.json.
Machine facts (host, ports, mode) live in context/ENVIRONMENT.md; a fresh clone creates it with
cp context/ENVIRONMENT.template.md context/ENVIRONMENT.md. Copy each .env.example to .env in the same folder and fill it. This page names the
files and their keys only. It never shows a value.
Rules:
./ddx-manage.sh stops with an error when a docker folder has no .env
(compose() in ddx-manage.sh).DDX_BFF_POOL_*) is server-only. The browser never gets the ddx-api address; it calls the
ddx-web BFF api/v1 ( §Rules).BETTER_AUTH_* and no DATABASE_URL ().
ddx-api needs DDX_API_AUTH_TRUSTED_ORIGINS to list the web origin, or sign-in fails with
INVALID_ORIGIN (403). Set DDX_BFF_TRUSTED_CLIENT_IP_HEADER and keep ddx-api DDX_API_HOST on
127.0.0.1 (or strip that header at the edge); without a trusted IP header the sign-in cap
is the per-account bucket only.NODE_ENV (development | production | test) is REQUIRED at ddx-api start
(validate-env.ts); a missing value stops the boot.REDIS_URL is REQUIRED (value shape: redis://<host>:16340). There is no in-memory fallback:
ddx-api stops the boot without it, and ./ddx-manage.sh start stops when the Redis container
is not healthy. Redis is reached only by ddx-api ().DDX_API_WEBHOOK_SECRET_KEY is REQUIRED: base64 of exactly 32 bytes (openssl rand -base64 32);
it encrypts outbound-webhook secrets at rest. Do not reuse BETTER_AUTH_SECRET.DDX_API_WEBHOOK_SOURCES makes every source answer 404. Each
listed source needs DDX_API_WEBHOOK_SOURCE_<NAME>_SECRET (≥ 32 chars), _EVENTS (subset of the
inbound event types) and _ORGS (org ids; * is refused when NODE_ENV=production).
DDX_API_WEBHOOK_NONCE_TTL_S must exceed 600. Protocol: §Webhooks.DDX_API_SEED_USER_PASSWORD is the one dev-only password of every seeded user
(ddx-api/prisma/seed/seed-auth.ts). The seed fails when it is not set../ddx-manage.sh./ddx-manage.sh is the ONE lifecycle owner (invariant 16). Never start a server another
way while a managed one holds the port. Dev servers run in the background, each in its own
process group; pid + pgid files live in .ddx-run/, logs in logs/ (previous run kept as
logs/<app>.prev.log).
Source: usage() and main() in ddx-manage.sh.
Dev cache: Next.js is 16.4.3. ddx-web/next.config.ts leaves the fs dev cache at its default (ON)
and sets experimental.turbopackGc, turbopackLazyDynamicImports and turbopackRustReactCompiler
to true. The 2026-10-23 trial of the cache was reverted for broken HMR, a 2.2 GB .next in ~1 h and a
stale route module; the 2026-10-24 re-trial and its revert rule (set turbopackFileSystemCacheForDev: false
on conflicting effects for the same key or a stale module) are in research/rq11-dev-latency.md
§16.4.3 re-trial. The 2048 MB auto-wipe in ddx-manage.sh guards the old failure
(.next/dev/cache/turbopack at 4-8 GB, /en > 55 s). Cold-compile probe (run right after
./ddx-manage.sh restart web --clean): sh scripts/dev-latency-probe.sh <label> (login, sign-in, projects,
register compile times, RSS, .next size). ddx-web logs come from one logger (ddx-web/src/lib/logger/logger.ts) in the same pretty layout as ddx-api: one annotated line per record, colour from chalk 6 (256 colours; the dev log file is a pipe, so LOG_COLOR=auto forces colour outside production; NO_COLOR turns it off). LOG_FORMAT=json or production writes one JSON line; LOG_LEVEL gates the level. Layout, LOG_* keys, queue and log access: . Scopes today: bff, sse-proxy, next.
UI validation is done in jev-browser (owner rule), not with curl loops or headless sweeps.
Every docker tier joins ONE bridge network, ddx-web-boot, created once by
ensure_network() on a pinned subnet (default 10.163.0.0/24, override with
DDX_NETWORK_SUBNET). If the network exists on another subnet, the script stops and
tells you to remove it first. Volumes are not touched.
The port map lives in ONE place: . Summary: web 16300, api 16310,
Postgres 16330, Redis 16340, MinIO 16350 / 16351, docs 16370 reserved and unused (the docs render inside
ddx-web at /[locale]/docs).
One Postgres instance holds six databases, one per Prisma client (): auth, main, audit, logs, ai, communication.
The seed is idempotent (upserts with deterministic ids — ddx-api/prisma/seed/data.ts).
Source: ddx-api/prisma/seed/data.ts (ORGS, ROLES, USERS). Every org gets one user
per role. The email is <role>@<org-slug>.test. The password is the value of
DDX_API_SEED_USER_PASSWORD in ddx-api/.env.
super-admin is a platform role: that user gets platformRole = 'super-admin' and joins
its org as org-admin (ddx-api/prisma/seed/seed-auth.ts); in any org it acts as org-admin
of that org ().
Sign in at /[locale]/auth/login. Login, register, OTP and select-org then land on
/[locale]/portal, which resolves the org (session's active org, else the only one, else
/auth/select-org) and the member role and redirects to /[locale]/portal/[orgSlug]/[role]
(). The portal nav has 13 items, shown per role from
ddx-web/src/lib/navigation/portal-registry.ts.
MinIO is reached ONLY by ddx-api. Browser and mobile media go through the ddx-web BFF
proxy (api/v1) to ddx-api. There is no presigned URL.
POST /orgs/:orgSlug/media (raw body), GET /orgs/:orgSlug/media (list:
page, limit ≤ 100, kind), GET …/media/:id, GET …/media/:id/content (Range → 206).
Org members: GET /orgs/:orgSlug/members (name + image only). Spec: api/openapi.json.pnpm db:seed needs MinIO up (it uploads the demo PDF and PNG).Permissions-Policy: camera=(self), microphone=(self)
(ddx-web/next.config.ts).supersedes .
./ddx-manage.sh docs).api/openapi.json — generated ddx-api OpenAPI document (./ddx-manage.sh docs).Last reconciled: 2026-10-25 · plan harness-alignment task 038
adr/
deploy/
research/
pnpm install