pnpm uses a symlink-based virtual store (.pnpm/) that breaks when
copied between Docker stages — Node can't resolve modules from the
copied tree, causing 'Cannot find module express' at startup.
Replace the broken COPY with a proper pnpm install --prod in the
runtime stage. Layer caching still applies: manifests + lockfile
are copied before source so the install layer is only rebuilt when
deps change.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Alpine's built-in 'users' group owns GID 100 and 'nobody' owns UID 99.
The old check tested by name (appgroup/appuser) which always passed,
then hit 'addgroup: gid 100 in use' on creation.
Now checks by GID/UID via getent — reuses the existing group/user if
the ID is already taken, only creates new ones when the ID is free.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removed the conditional COPY line that used 2>/dev/null || true —
shell operators are not valid in Dockerfile COPY instructions and
were being interpreted as literal paths. pnpm hoists all deps to
root node_modules via shamefully-hoist so the line was unnecessary.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>