From 1.2GB to 78MB: A Real Node.js Docker Image Optimization
Bloated Docker images are a silent tax on every team shipping containers. They slow CI, slow deploys, increase attack surface, and inflate registry bills. Most of the time, it's fixable in an afternoon.
I took a real Node.js + TypeScript production service and walked it down from 1.2GB to 78MB—a 94% reduction. Same app, same behavior, six measurable steps.
The Starting Point: 1.2GB
Most teams start with a Dockerfile like this:
FROM node:22
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
Build it:
$ docker build -t app:naive .
$ docker images app:naive
REPOSITORY TAG SIZE
app naive 1.21GB
1.21GB to ship about 4MB of compiled JavaScript. Here's how to fix it.
Step 1: Switch the Base Image (1.21GB → 412MB)
The node:22 tag is Debian-based and includes a full toolchain you don't need at runtime. The slim variant strips most of it.
| Image | Size |
|---|---|
| node:22 | 1.21GB |
| node:22-slim | 412MB |
| node:22-alpine | 178MB |
Alpine is even smaller but uses musl libc instead of glibc. Pure-JS apps run fine, but native modules (bcrypt, sharp, node-gyp) need extra care. For realism, I'll stick with slim.
Step 2: Use a .dockerignore (412MB → 388MB)
COPY . . copies node_modules, .git, .env files, and IDE folders into the image. Even if a later step overwrites node_modules, the layer persists.
Create a .dockerignore:
node_modules
npm-debug.log
.git
.gitignore
.env*
.vscode
.idea
coverage
dist
build
*.md
test
__tests__
Dockerfile*
.dockerignore
Small size win, big win on rebuild speed and security.
Step 3: Multi-Stage Build (388MB → 198MB)
You need TypeScript, eslint, and dev dependencies to build, but not to run. Multi-stage builds compile in one image and copy only artifacts into a clean runtime image.
# ---- builder ----
FROM node:22-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --omit=dev
# ---- runtime ----
FROM node:22-slim
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["node", "dist/index.js"]
Two key changes: npm ci for reproducible installs, and npm prune --omit=dev to strip dev dependencies. On a typical TypeScript service, that's half of node_modules gone.
Step 4: Layer Caching (Same Size, 5x Faster Rebuilds)
Order matters. Copy package*.json first, install, then copy the rest. Now npm ci is cached as long as dependencies don't change.
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
On a project with ~600 dependencies, cold rebuild dropped from 94 seconds to 18 seconds when only application code changed.
Step 5: Switch Runtime to Alpine (198MB → 96MB)
Keep the Debian-based builder for compatibility, but switch the runtime to Alpine. Compiled JS doesn't care about the base OS.
# ---- builder ----
FROM node:22-slim AS builder
...
# ---- runtime ----
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["node", "dist/index.js"]
If you have native modules, build them in a stage matching the runtime libc (use node:22-alpine as builder and add apk add --no-cache python3 make g++).
Step 6: Distroless (96MB → 78MB)
Google's distroless images contain only the Node runtime and TLS roots. No shell, no package manager, no curl.
# ---- runtime ----
FROM gcr.io/distroless/nodejs22-debian12
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["dist/index.js"]
Note: no shell means you must use exec form for CMD. For debugging, use the :debug tag which adds busybox.
The Full Picture
| Step | Image | Size | Reduction |
|---|---|---|---|
| 0. Naive | node:22 | 1.21GB | - |
| 1. slim base | node:22-slim | 412MB | -67% |
| 2. .dockerignore | node:22-slim | 388MB | -6% |
| 3. Multi-stage + prune | node:22-slim | 198MB | -49% |
| 4. Layer caching | node:22-slim | 198MB | (rebuild speed) |
| 5. Alpine runtime | node:22-alpine | 96MB | -52% |
| 6. Distroless | distroless/nodejs22 | 78MB | -19% |
Total: 94% reduction. Roughly 15× smaller.
What This Buys You
- Faster cold starts: Pulling 78MB vs 1.2GB on a Kubernetes node without cached layers is the difference between 4 seconds and 40 seconds.
- Smaller attack surface:
node:22has hundreds of CVEs; distroless has a handful. - Faster CI: Smaller images push and pull faster.
- Cheaper cross-region replication: Moving 6% of the bytes you used to.
What Didn't Make the Cut
- docker-slim: Can silently strip files loaded at runtime under conditions your tests don't cover.
- Static binaries with pkg/nexe: Freeze you to a specific Node version and break dynamic imports.
- scratch images: Missing CA certs, tzdata, and nameservers make them impractical.
The Takeaway
The naive Dockerfile is fine for a hackathon. For anything shipped more than once a week, these six steps pay for themselves in an afternoon. Copy the final Dockerfile, set up your .dockerignore, and stop paying the 1.2GB tax.


