# 0.2. Docker: Dockerfiles & Compose This page documents every Dockerfile and compose file in the repository — what each stage does, how the pieces fit, and the invariants that keep the stack deterministic. ## The files at a glance | File | Role | | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `Dockerfile` (root) | **Canonical image definition.** One file, many targets — dev and production for both apps. | | `.dockerignore` (root) | Keeps the build context small: excludes `**/node_modules`, `**/dist`, `**/.pnpm-store`, real `.env*`. | | `compose.yml` (root) | Development stack: `deps` (install), `server`, `dashboard`. | | `infra.compose.yml` | Backing services: PostgreSQL 16, MinIO, KeyDB, Playwright MCP sidecar (+ commented Ollama, SearXNG). | | `server/Dockerfile`, `dashboard/Dockerfile` | **Standalone per-app images** — kept for a potential split of the monorepo into separate repos. _Not_ used by `compose.yml`. | Compose project state is simple: app services share one external Docker network `triplef.io` (created by `infra.compose.yml`). --- ## Root `Dockerfile` The root Dockerfile builds everything from the **repository root context** so that the pnpm workspace — and above all the **single root `pnpm-lock.yaml`** — is the dependency authority for every stage. ``` FROM node:24 AS base ``` `base` carries only shared facts: corepack-prepared pnpm, and the environment the whole image relies on: | Variable | Why it exists | | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CI=true` | Non-interactive installs; with `--frozen-lockfile` this hard-fails on lockfile drift — by design. | | `PNPM_CONFIG_CONFIRM_MODULES_PURGE=false` | Lets pnpm replace changed `node_modules` without a TTY prompt. | | `PNPM_CONFIG_MINIMUM_RELEASE_AGE=0` | Disables the workspace's release-age gate inside images. | | `HUSKY=0` | Root `prepare` script runs husky — disabled in containers. | | `pnpm_config_store_dir=/repo/.pnpm-store` | pnpm 11 **only** honours `pnpm_config_*` env vars (the legacy `PNPM_STORE_DIR` is ignored). Store lives inside the mounted workspace → shared between host and containers, hardlink-friendly, persistent. | | `PATH="$PNPM_HOME:$PATH"` | Corepack shims on PATH. Split into its own ENV line — same-line self-references warn under BuildKit. | No pnpm _version_ is baked into the image on purpose: `package.json`'s **`packageManager: pnpm@11.17.0`** field drives corepack — update it once, every image follows. ### Targets | Target | Parent | Purpose | | ------------------------------------------------ | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `local` | `base` | Image for the compose dev stack. Contains **no sources** — the repo is bind-mounted at runtime. `pnpm_config_verify_deps_before_run=false` (see below). | | `deps` | `base` | Copies `pnpm-lock.yaml`, `pnpm-workspace.yaml`, root + app `package.json`s and `server/prisma`, then `pnpm install --frozen-lockfile` with a BuildKit cache mount on the store. Reused as parent of all build stages. | | `buildserver-dev` / `buildserver-prod` | `deps` | Copy the _minimal_ server build context (`tsconfig*.json`, `shims.d.ts`, `src/`), run `pnpm --filter "{server}" run build[:prod]`, then `pnpm deploy --legacy` into `/prod/server`. | | `builddashboard` / `builddashboard-dev` | `deps` | Same idea for the dashboard (`build` = `vue-tsc -b && vite build`). | | `server-development` / `server-production` | `base` | Runtime stages receiving the deployed app + compiled `dist`. Production entrypoint: `pnpm run start:node` (`node dist/main`). | | `dashboard-development` / `dashboard-production` | `base` | Analogous; production serves `vite preview` on 4173. | Two deliberate choices worth knowing: - **Minimal server build context.** `server/tsconfig.json` `include`s stray root-level configs (`eslint.config.ts`, `vitest.config.ts`, …). If those files are present in the build context, tsc recomputes the common root above `src/` and nests output into `dist/src/` — breaking `node dist/main`. Copying only what the build needs keeps `dist/main.js` deterministic. - **`pnpm deploy --legacy`.** pnpm v10+ refuses `deploy` unless `injectWorkspacePackages=true` (injected mode). The apps do not depend on each other, so the legacy deploy path is correct and needs no workspace-semantics change. ### Building images yourself ```bash docker compose build # dev image (target: local) docker build --target server-production . # context is the REPO ROOT docker build --target dashboard-development . ``` Production images are self-contained (deps baked), sized ≈2 GB thanks to the flattened root context + `.dockerignore` (previously 3.2–3.6 GB with accidental `node_modules` copies). --- ## `compose.yml` — the development stack ``` deps ──(completed)──▶ server ──(started)──▶ dashboard ``` | Service | Command | Notes | | ----------- | ------------------------------------------------------------ | ---------------------------------------------------------------- | | `deps` | `pnpm install --frozen-lockfile --prefer-offline` at `/repo` | One-shot; exits 0; the **single owner of `node_modules` state**. | | `server` | `pnpm start:dev` in `/repo/server` | NestJS watch mode, port 3000, `restart: on-failure`. | | `dashboard` | `pnpm dev` in `/repo/dashboard` | Vite, port 5173, host 0.0.0.0, path `/dashboard/`. | All three mount the repository at `/repo` — there is no per-service copy of anything, and the install `deps` produces is immediately and identically visible in the app containers (and on the host). ### The install model (this replaces the delete-dance) One invariant: **one workspace, one lockfile, one `node_modules` layout.** - Host and containers share the workspace-shared pnpm layout: member packages link into the root `.pnpm` store (`server/node_modules/typescript → ../../node_modules/.pnpm/…`). - pnpm **continues whichever layout existing state indicates**. If a member directory holds a standalone layout (own `.pnpm`, own `pnpm-lock.yaml`) — e.g. produced by the standalone per-app Dockerfiles or a stray in-package install — a later install happily re-enters that regime, regenerates per-package lockfiles, and the next frozen install explodes with `ERR_PNPM_OUTDATED_LOCKFILE`. The pre-rework habit of deleting `node_modules` + lockfiles before rebuilds was exactly this oscillation being reset by hand. - Guardrails now in place: - `deps` runs installs **only at the repo root**, always `--frozen-lockfile` (fails loudly on drift instead of silently rewriting the lockfile). - pnpm 11's `verifyDepsBeforeRun=install` default (every `pnpm run` performs a deps check that may silently install) is disabled in runtime stages via `pnpm_config_verify_deps_before_run=false` — an app container at boot never rewrites dependency state. - Per-app `pnpm-lock.yaml` files are gone; the store `/repo/.pnpm-store` is bind-mounted, already git-ignored, and survives container recreation. ### Changing dependencies ```bash git pull # pnpm-lock.yaml changed? docker compose up deps # converge node_modules (seconds, warm store) docker compose up -d --force-recreate # restart app containers ``` Or add something: `pnpm --filter server add ` on the host or via `docker compose exec`. ### Infra lifecycle `infra.compose.yml` is deliberately separate so `docker compose down` never destroys data volumes (`postgres_data`, `minio_data`, `keydb_data`). KeyDB is tuned by `server/keydb.conf` (mounted read-only into the container). The **`playwright-mcp`** service runs the official `mcr.microsoft.com/playwright/mcp` headless-chromium sidecar. It is internal-only: the server reaches it at `http://playwright-mcp:8931/mcp` (no host port published to the LAN), with a single loopback-only publish `127.0.0.1:8931:8931` so host-side MCP clients (pi via `pi-mcp-extension`) can reach it as `http://localhost:8931/mcp`. `--allowed-hosts` entries must match the Host header verbatim (port included). It needs `shm_size: 2gb` for Chromium. See the **playwright** skill for operations and debugging. --- ## Standalone per-app Dockerfiles `server/Dockerfile` and `dashboard/Dockerfile` build each app with **`./server` / `./dashboard` as context** and generate their own standalone pnpm setup at build time (no workspace lockfile involved). They exist because the monorepo may be sliced back into separate repositories — keep them working, but do not mix them with the workspace flow: - Their builds never touch your working tree (installs happen in image layers). - Running their containers against the repo bind-mount _will_ flip `node_modules` into the standalone regime (they run `pnpm install` per-app). If that happens, reset once per **0.1-quick-start → rule 3**. - `server/.dockerignore` and `dashboard/.dockerignore` keep their contexts lean (previously the dashboard context shipped 1.6 GB including `node_modules` into the daemon). --- ## Troubleshooting | Symptom | Cause | Fix | | -------------------------------------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | `ERR_PNPM_OUTDATED_LOCKFILE` at `deps` or image build | Root `package.json` drift vs `pnpm-lock.yaml` | `pnpm install` at repo root (host, once), commit the lockfile | | Apps crash-loop, modules “not found” right after pulling | Stale `node_modules` vs new lockfile | `docker compose up deps && docker compose up -d --force-recreate` | | Per-app `pnpm-lock.yaml` reappears | Something ran `pnpm install` inside a member dir against stale state | Remove them, reset per rule 3, and run root installs only | | First dashboard start is slow | Vite dependency pre-bundling on the fresh volume | Normal; subsequent starts are instant | | `EACCES` writing under `/repo` | Container UID vs host file ownership | Services run as `1000:1000`; keep project files owned by your user | | Production image fails at `node dist/main` with missing module | Server build saw stray root-level `.ts` configs (`dist/src/` nesting) | Already prevented via minimal build context — do not blanket-copy `server/` into build stages | ## Design principles (recap) 1. **Root lockfile is law** — installs happen at the workspace root with `--frozen-lockfile`. 2. **One owner for dependency state** — the `deps` service; app containers merely consume it (verify-before-run disabled). 3. **Bind mounts over COPY for dev** — hot reload, zero rebuilds for source edits. 4. **Minimal build contexts** — deterministic tsc output, small images, honest `.dockerignore`. 5. **Corepack, not baked versions** — `packageManager` is the single version truth.