build(docker): prune devDependencies from the production and cloud images

The production stage copied the entire build stage `/app` wholesale,
including every workspace member's devDependencies (typescript, vite,
vitest, storybook, rolldown, ...) and unrelated workspace members' own
dependencies (ui's client-side bundle deps like mermaid, lucide-react),
none of which the running server touches. That alone accounted for
~1.5GB of the shipped image.

Add a `pruned-app` stage, forked from `build` after all builds finish,
that re-resolves node_modules with `pnpm install --prod --frozen-lockfile
--filter='@paperclipai/server...'` -- server plus everything it actually
depends on, transitively, production-only. `production` now copies from
`pruned-app` instead of `build`.

The prune has to live in its own stage rather than in `build` directly:
`cloud-plugins` and `cloud-server-deps` (declared later in the file) both
fork from `build` and still need its full devDependency toolchain
(typescript, etc.) to compile their own TypeScript at image build time.

Move `tsx` from server's devDependencies to dependencies. It looked like
a dev tool, but the image's own ENTRYPOINT imports it directly
(`--import ./server/node_modules/tsx/...`) to transpile the workspace
packages the server consumes by TypeScript source (their `exports` field
points at `./src/*.ts`, not a prebuilt `dist`) -- it's genuinely required
at runtime, and a naive prod-prune would have deleted it and broken every
boot.

Verified locally: the `cloud` target drops from 7.5GB to 5.53GB and the
`production` target lands at 5.26GB. Boot-tested the built `cloud` image:
embedded Postgres initializes, all migrations apply, `/api/health`
returns 200, the UI serves, `@sentry/node` resolves to the version
`server/package.json` declares, and tini/orphan-reaping passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RD53WaVFqm8pbntKZ5seWy
This commit is contained in:
Nicky Leach 2026-09-04 15:29:57 -07:00
parent d2d647c34b
commit d9d32b1f4a
No known key found for this signature in database
GPG Key ID: 4861541D36B2037E
2 changed files with 30 additions and 2 deletions

View File

@ -100,6 +100,34 @@ RUN pnpm --filter @paperclipai/server build
RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1)
RUN rm -rf packages/paperclip-runner/runner/target
# Prune the workspace down to @paperclipai/server's own production
# dependency graph, in a stage of its own rather than in `build` directly:
# `cloud-plugins` and `cloud-server-deps` below both fork from `build` and
# need its full devDependency toolchain (typescript, etc.) still intact to
# build their own TypeScript at image build time, so the prune can't
# happen in `build` itself without breaking them.
#
# `pnpm install --frozen-lockfile` in the deps stage installed every
# workspace member's devDependencies plus every member's own dependencies
# -- ui's bundler/storybook toolchain, other workspace packages' test
# tooling, none of which the running server touches -- into one hoisted
# node_modules that `production` below would otherwise copy wholesale. The
# builds above are already done, so re-resolving with --prod and a
# `server...` filter (server plus everything it actually depends on,
# transitively) is safe and drops the unused weight.
#
# tsx is deliberately NOT pruned: server/package.json lists it as a
# production dependency (not dev) because the ENTRYPOINT below imports it
# directly (`--import ./server/node_modules/tsx/...`) to transpile the
# workspace packages the server consumes by TypeScript source -- their
# `exports` field points at `./src/*.ts`, not a prebuilt `dist` -- so tsx
# has to survive any devDependency prune.
FROM build AS pruned-app
RUN find . -maxdepth 4 -type d -name node_modules \
-not -path '*/node_modules/*/node_modules*' -exec rm -rf {} + \
&& pnpm install --prod --frozen-lockfile --ignore-scripts \
--filter='@paperclipai/server...'
FROM base AS production
ARG USER_UID=1000
ARG USER_GID=1000
@ -131,7 +159,7 @@ RUN echo "cli-tools-epoch: ${CLI_TOOLS_CACHE_EPOCH}" \
COPY scripts/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
COPY --chown=node:node --from=build /app /app
COPY --chown=node:node --from=pruned-app /app /app
ENV NODE_ENV=production \
HOME=/paperclip \

View File

@ -82,6 +82,7 @@
"pino-pretty": "^13.1.3",
"sharp": "^0.35.4",
"ssh2": "^1.17.0",
"tsx": "^4.23.12",
"ws": "^8.21.3",
"zod": "^4.4.3"
},
@ -100,7 +101,6 @@
"@types/ws": "^8.18.1",
"cross-env": "^10.1.0",
"supertest": "^7.0.0",
"tsx": "^4.23.12",
"typescript": "^7.0.2",
"vite": "^8.2.2",
"vitest": "^4.1.11"