Skip to content

0.2 docker

wiki[bot] edited this page Aug 8, 2026 · 2 revisions

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.jsons 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 includes 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

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

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 <pkg> 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 versionspackageManager is the single version truth.

Clone this wiki locally