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.

ImageSize
node:221.21GB
node:22-slim412MB
node:22-alpine178MB

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

StepImageSizeReduction
0. Naivenode:221.21GB-
1. slim basenode:22-slim412MB-67%
2. .dockerignorenode:22-slim388MB-6%
3. Multi-stage + prunenode:22-slim198MB-49%
4. Layer cachingnode:22-slim198MB(rebuild speed)
5. Alpine runtimenode:22-alpine96MB-52%
6. Distrolessdistroless/nodejs2278MB-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:22 has 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.