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.
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:
dockerfileFROM 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.