
Shrinking a NestJS Docker image from 735 MB to 253 MB
Six Dockerfiles for the same NestJS app, measured. The multi-stage build everyone copies stops at 417 MB, and the obvious next fix makes it bigger.
Every NestJS Docker guide stops at the same multi-stage Dockerfile. I built it, measured it, and then kept going. Two of the results surprised me.
Everything here is one nest new app with pnpm and nothing added, on Docker 28.5.1 and linux/arm64. These numbers are the floor. Yours will be bigger.
What does a normal NestJS Dockerfile look like?
Like this. Do not copy it yet.
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN corepack enable pnpm && pnpm install --frozen-lockfile
RUN pnpm build
CMD ["node", "dist/main"]This will:
- Pull the Node 22 Alpine image.
- Copy your whole project in.
- Install the dependencies.
- Build the app.
- Start it.
It works. Now check what it weighs:
docker image ls735 MB.
That COPY . . sitting before the install drags your local node_modules in with it, which on my machine was 139 MB of macOS-arm64 binaries the image will never run.
Cool. So what do we do about that?
Two things. Tell Docker what to leave behind, and copy the lockfile before the source.
FROM node:22-alpine
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
CMD ["node", "dist/main"]node_modules
dist
.git
coverage
test547 MB. Nearly 200 MB for five lines. The install layer caches properly now too: it only rebuilds when the lockfile changes, instead of every time you touch a file.
Still too big. Time for multi-stage
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:22-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/main"]417 MB. The TypeScript source, the NestJS CLI and the compiler are all gone.
Most guides stop right here. The image still has every devDependency in it, because node_modules came over wholesale from the builder.
So install only production deps, right?
That is what I thought. Watch what happens.
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN corepack enable pnpm
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod --node-linker=hoisted
COPY --from=builder --chown=node:node /app/dist ./dist
USER node
CMD ["node", "dist/main"]539 MB. That is 122 MB worse than the version that still had every devDependency in it.
Installing in the runner puts pnpm, and the store it unpacks into, straight into the layer you ship. You dropped the devDependencies and picked up a package manager.
So resolve them somewhere you throw away:
FROM node:22-alpine AS deps
WORKDIR /app
RUN corepack enable pnpm
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod --node-linker=hoisted
FROM node:22-alpine AS builder
WORKDIR /app
RUN corepack enable pnpm
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/dist ./dist
USER node
CMD ["node", "dist/main"]253 MB. There we go.
--node-linker=hoisted earns its place here. pnpm's default layout is a symlink farm pointing into node_modules/.pnpm. Copying the whole directory does work, but a flat tree means the COPY does not care about the store layout at all.
Can we go smaller?
Barely, and here is why.
node:22-alpine is 228 MB on its own.
So that 253 MB image is 25 MB of my app sitting on a 228 MB base. Everything up to this point was about the app. Everything past it is about the base image, which is a different problem.
The usual next move is distroless:
FROM gcr.io/distroless/nodejs22-debian12 AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
CMD ["dist/main.js"]236 MB. Seventeen megabytes.
Take distroless anyway, just not for the size. There is no shell in it, no package manager, no busybox, so a process that gets execution has almost nothing to work with. That is the real reason to use it.
So my deploys get faster, right?
Mostly no. This is the part nobody mentions.
docker image ls reports the uncompressed size sitting on your disk. A registry does not store that. It stores gzipped layers, and node:22-alpine travels as four of them totalling 58 MB, not 228 MB.
Layers are content addressed too, so once a host has pulled that base image it never pulls it again. I timed a redeploy where only dist had changed: 52 ms.
So a smaller image buys you a quicker cold start on a fresh host, a shorter vulnerability report, and no compiler sitting in production. All good things. Just not the one everyone promises.
The bug none of this catches
While testing shutdowns I found something with nothing to do with size, that matters more than all of it.
Three lines whose only job is to report the signal:
process.on('SIGTERM', () => { console.log('GOT SIGTERM'); process.exit(0); });
setInterval(() => {}, 1e9);Same image, same app, three CMD forms, docker stop -t 15 on each:
CMD | docker stop took | Exit code | Handler ran |
|---|---|---|---|
node app.js | 176 ms | 0 | yes |
npm start | 175 ms | 0 | yes |
pnpm start | 15,294 ms | 137, SIGKILL | no |
pnpm does not forward SIGTERM to the child process. npm does.
So if your CMD goes through pnpm, your app never hears about the shutdown. Docker waits out the whole grace period, gives up, and kills it.
And app.enableShutdownHooks() will not rescue you. It wires the listeners up perfectly well. Nothing ever calls them.
The sneaky part: this looks fine in production. ECS and Kubernetes drain connections before they send SIGTERM, so your requests are safe. Your connection pools and shutdown hooks are not. And both sit through a 30 second timeout on every deploy, waiting on a process that stopped listening.
CMD ["node", "dist/main"]. Not CMD ["pnpm", "start:prod"].
Oh, and one more trap
A production install can fail on a script you forgot you had:
. prepare: sh: husky: not found
Error: ERR_PNPM_EXECUTOR_LIFECYCLE_SCRIPT_FAILED"prepare": "husky" in package.json, husky in devDependencies, and pnpm install --prod has nothing to run. HUSKY=0 will not save you either: the missing binary is the one that reads that variable.
Use --ignore-scripts on the production install. As a bonus it stops the @nestjs/core and @scarf/scarf postinstall telemetry running inside your image.
The whole table
| Dockerfile | Size |
|---|---|
Single stage, no .dockerignore | 735 MB |
Single stage, with .dockerignore | 547 MB |
Multi-stage, copying node_modules | 417 MB |
| Multi-stage, production install in the runner | 539 MB |
| Multi-stage, production deps in their own stage | 253 MB |
| Same, on distroless | 236 MB |
node:22-alpine, empty | 228 MB |
Three things worth keeping:
Resolve production dependencies in a stage you throw away. Doing it in the runner ships the package manager along with them.
Weigh your base image before you optimise anything. Mine was 90 percent of the final size. There were 25 MB of my own work in there to argue about.
Run docker stop and time it. Ten seconds to find out whether your app shuts down or gets killed, and no image size will ever tell you.
The starting point for this was Richard Solomou's multi-stage production Dockerfile for NestJS, which is no longer up and is worth reading on the Wayback Machine. Every number here is my own, because his were npm on Node 18 and mine are pnpm on Node 22, and pnpm turned out to be where the interesting parts were.