Skip to content
All posts
April 22, 2025·7 min read

How we shrunk our Docker images by 90% (and CI runtimes by 50%)

A pragmatic walkthrough of the layer audit, multi-stage builds, and base-image swap that cut our NestJS images from ~1.2GB to ~120MB.

DockerDevOpsCI/CDNestJS

The starting point

Our NestJS service images hovered around 1.2 GB. CI was slow, cold starts on ECS were painful, and we paid for it in deploy latency every time a regional rollout happened.

The goal was simple: get this under 200 MB without sacrificing build reliability.

What actually moved the needle

Three changes did 90% of the work. Everything else was rounding error.

1. Multi-stage builds

The big one. We were copying the entire monorepo, including node_modules, dev deps, test fixtures, and source maps, into the final image. A multi-stage build splits this cleanly:

dockerfile
FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production COPY package*.json ./ RUN npm ci --omit=dev COPY --from=builder /app/dist ./dist USER node CMD ["node", "dist/main.js"]

2. Base image swap

We moved from node:20 (~380MB) to node:20-alpine (~50MB). Native module rebuilds bit us once on bcrypt. The fix was installing build deps in the builder stage only.

3. .dockerignore discipline

Half our image size was node_modules, .git, and test snapshots leaking in. A strict .dockerignore:

node_modules
.git
*.log
coverage
.env*
tests/__fixtures__

CI impact

Layer caching combined with smaller pull sizes brought our deploy pipeline from ~9 min → ~4.5 min. Cache hit rates jumped because layers stayed deterministic.

The real win wasn't speed. It was making rollbacks _feel safe_. When a deploy takes 4 minutes, on-call doesn't hesitate.

What I'd do differently

Distroless. We didn't go there because Alpine got us "good enough," but gcr.io/distroless/nodejs20 is the next step if you want truly minimal attack surface.