Keeping the Prisma CLI out of a Bun runtime image

Keeping the Prisma CLI out of a Bun runtime image

This site's runtime Docker image was 423 MB. A large share of that was Prisma CLI tooling the server never imported — migration engines, Studio, a PGlite dev database pulled in as transitive dependencies. After pulling it out, the image is 267 MB with no functional change to the request path. The win is purely on disk: runtime memory is unchanged, because none of the excluded code was ever imported at steady state. Pull time, storage, and cold-start disk I/O get cheaper; RSS does not.

The fix is one Bun install flag plus a workflow change for where migrations run. The interesting part is why the obvious fix — moving prisma to devDependencies — doesn't do anything on its own.

The baseline

The multi-stage Dockerfile has a deps-prod stage that installs only what the runtime needs, and a builder stage with the full install plus prisma generate. The runtime image is assembled from deps-prod's node_modules plus the generated Prisma client layered on top. One caveat up front: this works because the server uses the @prisma/adapter-pg driver adapter, not Prisma's built-in query engine. A setup that still relies on the engine binaries at runtime can't drop them.

--production in the deps-prod stage was supposed to exclude the Prisma CLI because the server only needs @prisma/client and @prisma/adapter-pg. Migrations were applied by bunx prisma migrate deploy in the container's entrypoint, so the CLI only lived for a few seconds before the server took over.

Moving prisma to devDependencies is not enough

1 "dependencies": { 2 "@prisma/adapter-pg": "^7.1.0", 3 "@prisma/client": "^7.1.0", 4- "prisma": "^7.1.0", 5 ... 6 }, 7 "devDependencies": { 8+ "prisma": "^7.1.0", 9 ... 10 }

Rebuilt. Checked the image. Same size.

Inside the runtime node_modules/, all the heavy Prisma gear was still there: prisma, @prisma/engines, @prisma/studio-core, plus @electric-sql/pglite, effect, and @prisma/dev. None of these are in package.json anywhere.

The optional-peer trap

$ bun pm why prisma [email protected] ├─ dev andreasbergstrom.dev-new (requires ^7.1.0) └─ optional peer @prisma/[email protected] (requires *) └─ andreasbergstrom.dev-new (requires ^7.1.0)

@prisma/client declares prisma as an optional peer dependency so tooling can warn on CLI/client version mismatches. Bun installs optional peers by default. The side effect is that in production the peer edge drags the CLI back in even when prisma is listed only in devDependencies.

From there the transitive closure explains the rest: prisma depends on @prisma/engines, @prisma/studio-core, @prisma/config (which pulls effect), and @prisma/dev (which pulls @electric-sql/pglite). Roughly 160 MB of code the server never imports.

--omit=peer

Bun's install accepts --omit=peer, which skips peer deps entirely. Combined with --production, the deps-prod stage becomes:

1RUN bun install --frozen-lockfile --production --omit=peer

After this, the runtime @prisma/ directory contains exactly the packages the app imports:

@prisma/adapter-pg @prisma/client @prisma/client-runtime-utils @prisma/debug @prisma/driver-adapter-utils

node_modules drops from 359 MB to 197 MB. --omit=peer skips all peer deps, not just optional ones, which is fine here because any peer the app actually needs at runtime is already listed in dependencies (React, pg, etc.); if one were missing, the first import of it would fail loudly.

The Dockerfile COPY line that undoes the work

The runtime-assembly stage layers the generated Prisma client on top of deps-prod's lean tree, because prisma generate writes output into node_modules/.prisma/client/:

1COPY --from=deps-prod /app/node_modules ./node_modules 2COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma 3COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma # <- this

That last line pulls the entire @prisma/* tree from the builder — including the engines and Studio code just excluded. The fix is to narrow it to the one package prisma generate actually touches:

1-COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma 2+COPY --from=builder /app/node_modules/@prisma/client ./node_modules/@prisma/client

The assembly stage also strips *.md, *.d.ts, *.map, test/, and docs/ from the copied tree. Both changes together take the final image from 423 MB to 267 MB.

Where migrations run now

Dropping the Prisma CLI from the runtime image means the container can't run bunx prisma migrate deploy at boot. Options:

  1. Keep the CLI in the runtime image for a Railway Pre-Deploy Command. Works, but gives up the size win.
  2. Run migrations from CI, with a GitHub Actions workflow that hits the Railway DB before Railway builds.
  3. Run migrations from the dev machine, against the Railway DB, before pushing. Zero new infrastructure.

Option 3 is the shortest path for a personal project. Railway exposes a public TCP-proxy URL for the managed Postgres; the in-cluster postgres.railway.internal URL used at runtime is not reachable from a laptop. Storing the public URL in .env as PRODUCTION_DATABASE_URL and writing a small Bun script makes bun run deploy the canonical way to ship:

1#!/usr/bin/env bun 2import { $ } from "bun"; 3 4const prodUrl = process.env.PRODUCTION_DATABASE_URL; 5if (!prodUrl) { 6 console.error( 7 "PRODUCTION_DATABASE_URL is not set. Add the Railway TCP-proxy public URL to .env.", 8 ); 9 process.exit(1); 10} 11 12process.env.DATABASE_URL = prodUrl; 13 14await $`bunx prisma migrate deploy`; 15await $`git push`;

If migrations fail, the push is skipped. The remaining failure mode is migrations succeeding but the Railway build that follows failing — in that case prod schema is briefly ahead of prod code, which is the safer of the two asymmetries but still worth naming. Also: bun run's script runner doesn't expand $VAR inside a package.json script string, so a small Bun file is cleaner than trying to inline this.

What's left

267 MB is close to the floor for this stack. The single biggest chunk in node_modules is @prisma/client at 25 MB — generated schema code plus the Prisma runtime. The rest is the Bun Alpine base, the app bundles at a couple of MB, and the dependencies the app actually uses. Further wins would mean either bun build --compile to drop node_modules entirely, or switching off the prisma-client-js generator — both separate projects.