Skip to content

fix(build): give production builds their own container (#93) - #94

Merged
TortoiseWolfe merged 3 commits into
mainfrom
fix/build-container-collision
Aug 3, 2026
Merged

fix(build): give production builds their own container (#93)#94
TortoiseWolfe merged 3 commits into
mainfrom
fix/build-container-collision

Conversation

@TortoiseWolfe

Copy link
Copy Markdown
Owner

Fixes #93.

docker compose exec spoketowork pnpm build — the pattern CLAUDE.md teaches for every other command — failed on a clean checkout of main:

✓ Compiled successfully
unhandledRejection Error: Cannot find module './4873.js'

next dev runs inside that container, so build and dev server both owned /app/.next. build opened by running clean:next (rm -rf .next/*), WATCHPACK_POLLING made the dev server recompile instantly, and its runtime — which resolves chunks flat — overwrote the production one. Confirmed by mtime: the dev server rewrote webpack-runtime.js six seconds after the build wrote chunks/4873.js.

This repo documented the error verbatim in docs/BUILD-ISSUES.md until 335beb4 deleted it; its remedy was "let CI validate the build." ScriptHammer root-caused it as its #293 and fixed it structurally. This ports that fix.

Changes

  • builder compose service — same image, its own builder_next:/app/.next volume. profiles: ['build'] is load-bearing: without it docker compose up starts a second dev server, since builder inherits CMD ["pnpm","run","dev"].
  • next_cache named volume replaces the .next tmpfs on spoketowork. That tmpfs was inert on any container created before it was declared, leaving .next on the WSL2 bind mount — separately documented upstream as the cause of spurious Cannot find module for page errors, the other failure this build produced.
  • Dockerfile pre-creates /app/.next node-owned. Docker seeds a named volume from the image at the mount point; with no such path the volume is root-owned and the dev server crash-loops. Must run before USER node.
  • clean:next deleted — redundant with cleanDistDir: true and destructive. Also removed from test and from .husky/pre-commit.
  • Guard + callersvalidate-ci.sh refuses to build inside the dev container; test-suite.sh and test:pwa:build repointed at builder. test-suite.sh's "production build corrupts .next permissions" restart hack is removed; that was this bug misread as a permissions quirk.
  • src/pages/_document.tsx deleted — a bare Html/Head/Main/NextScript identical to Next's default. docs/TECHNICAL-DEBT.md:413-419 already recorded that no Pages-Router shim is needed here.
  • Docs — CLAUDE.md and README now show the correct command; both previously taught the broken one.

Regression test

With the dev server serving, in the exact state that failed:

DEV_BEFORE=200
BUILD_EXIT=0
DEV_AFTER=200
OUT_FILES=68

Notes for review

  • pgrep is not in node:22-slim. The upstream guard uses it, so ported verbatim it exits "command not found" and silently never fires. Replaced with a /proc/*/cmdline scan, verified in both directions (detects in dev, silent in builder).
  • Incidental fix: NODE_OPTIONS='...' pnpm run clean:next && vitest applied the 4 GB heap to rm -rf, not vitest — VAR=x cmd1 && cmd2 scopes to cmd1. The OOM mitigation from specs 042/043 had never taken effect. It now does. Suite is green (388 files) but worth watching CI memory.
  • Deleting _document.tsx does not remove the pages compiler — Next synthesizes /_app, /_document, /_error manifest entries regardless. It removes the compiled _document.js that appeared in the crash's require stack.

Local: type-check clean, lint 0 errors, 388 test files pass.

🤖 Generated with Claude Code

TortoiseWolfe and others added 3 commits August 2, 2026 11:21
…g .next

`docker compose exec spoketowork pnpm build` — the pattern CLAUDE.md teaches
for everything else — failed on a clean checkout of main:

    ✓ Compiled successfully
    unhandledRejection Error: Cannot find module './4873.js'

`next dev` runs inside that container (Dockerfile CMD), so build and dev server
both owned /app/.next. `build` opened by running `clean:next` (rm -rf .next/*),
WATCHPACK_POLLING made the dev server notice instantly and recompile into the
same directory, and its runtime — which resolves chunks flat — overwrote the
production one that had emitted chunks/4873.js. Caught by mtime: the dev
server rewrote webpack-runtime.js six seconds after the build wrote the chunk.

Changes:

- package.json: drop `clean:next` from `build`, delete the script. It was
  redundant with `cleanDistDir: true` (which cleans the correct dist dir at the
  correct time) and destructive.
- package.json: drop it from `test` too. Note this incidentally fixes a latent
  bug — `NODE_OPTIONS='...' pnpm run clean:next && vitest` applied the 4GB heap
  to `rm -rf`, not to vitest, so the OOM mitigation from specs 042/043 had
  never actually taken effect.
- .husky/pre-commit: stop wiping .next on every commit.
- docker-compose.yml: add a `builder` service — same image, `profiles: ['build']`,
  its own `builder_next:/app/.next` volume. The profile is load-bearing: without
  it `docker compose up` starts a second dev server, since builder inherits
  CMD ["pnpm","run","dev"].
- docker-compose.yml: replace the `.next` tmpfs on `spoketowork` with a named
  `next_cache` volume. The tmpfs was inert on any container created before it
  was declared, leaving .next on the WSL2 bind mount — which is separately
  documented upstream as the cause of spurious "Cannot find module for page"
  PageNotFoundErrors, the other error this build produced.
- docker/Dockerfile: pre-create /app/.next node-owned before `USER node`.
  Docker initialises a named volume from the image at the mount point; with no
  such path in the image the volume is created root-owned and the dev server
  crash-loops on "not writable by user node (UID 1000)". Found the hard way.
- next.config.ts: `distDir: process.env.NEXT_DIST_DIR || '.next'` as a
  secondary escape hatch for one-off concurrent builds.

Verified: with the dev server serving 200, `docker compose run --rm builder
pnpm build` now exits 0 and exports 68 pages, and the dev server still serves
200 afterwards. That is the exact scenario that failed before.

Refs #93

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three call sites still built via the dev-server container, which is the
collision fixed in the previous commit:

- scripts/validate-ci.sh: the IN_DOCKER branch ran plain `pnpm build`, and its
  host branch stopped and restarted the dev server around every build. Now it
  refuses inside the dev container (naming the correct command) and uses the
  `builder` service from the host, so the dev server is left alone.
- scripts/test-suite.sh: built via $CMD_PREFIX (`docker compose exec -T
  spoketowork`). Repointed at `builder`. Its "production build corrupts .next
  permissions - restart container to fix" workaround is now unnecessary and is
  removed — that was this bug, misread as a permissions quirk.
- package.json test:pwa:build: same substitution. `out/` stays on the bind
  mount, so serving it from the dev container still works.

The guard scans /proc rather than using pgrep, which is NOT available in
node:22-slim — the ported-verbatim `pgrep -f` version exited "command not
found" and the guard silently never fired. A guard that always passes is worse
than no guard, so this was caught by testing both directions rather than only
the failing one.

Verified in both directions, with the detection fed via stdin so the test
harness's own argv cannot contain the pattern and self-match (the first
version of this test did exactly that and reported a false positive):
dev container → DETECTED (refuses); builder container → not detected (builds).

Refs #93

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
src/pages/_document.tsx was the only file under src/pages/ — a bare
Html/Head/Main/NextScript document identical to Next's own default, tracked
since the initial commit. docs/TECHNICAL-DEBT.md already recorded that this
project needs no Pages-Router shim ("Tested build without any Pages Router
files - works perfectly. No dummy files or workarounds needed"), and then the
file stayed.

It was the entry in the failing require stack, and `.next/server/pages/
_document.js` is no longer compiled without it. To be precise about what this
does and does not achieve: Next still synthesizes /_app, /_document and
/_error entries in pages-manifest.json regardless — that is unavoidable and
normal. This removes a redundant compiled artifact, not the pages compiler.

Docs, where the wrong pattern was being taught:
- CLAUDE.md: build via `docker compose run --rm builder pnpm build`, with the
  `exec` form shown explicitly as wrong. The section previously offered
  "docker compose down && docker compose up fixes .next issues", which was
  treating the symptom.
- README.md: add the build command to the contributor list, which omitted it.
- specs/044-simplify-next-ride/quickstart.md: dropped a `pnpm run clean:next`
  step that would now fail — and that Vitest never needed, since it does not
  read .next.
- docs/TECHNICAL-DEBT.md: stop referencing the deleted script.

Refs #93

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@TortoiseWolfe
TortoiseWolfe merged commit b1d7aa0 into main Aug 3, 2026
17 of 21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pnpm build fails in the dev container: next dev and next build share /app/.next

1 participant