From d9d32b1f4adbb98d94d724a80abc0343285d1e1a Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Fri, 4 Sep 2026 15:29:57 -0700 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01RD53WaVFqm8pbntKZ5seWy --- Dockerfile | 30 +++++++++++++++++++++++++++++- server/package.json | 2 +- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index b51a2cfa97..48dbfe35b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/server/package.json b/server/package.json index fdeaf13f13..5c3f83ff21 100644 --- a/server/package.json +++ b/server/package.json @@ -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"