Slimming Docker Images by 90%: Multi-Stage Builds, Distroless, and BuildKit Caching in 2026
When I first deployed a Node.js application back in 2019, my Docker image weighed in at a hefty 1.4GB. Pull times were painful, CI minutes were bleeding money, and every security scan returned hundreds of vulnerabilities I had no hope of triaging. Fast forward to 2026, and that same application now ships as a 78MB image—a 94% reduction. In this guide, I'll walk you through the exact techniques I use daily to shrink production Docker images without sacrificing functionality.
If you're serious about docker image optimization, this is the playbook. We'll cover docker multi-stage builds, distroless containers, and buildkit caching strategies that transformed my deployment workflow.
Why Bloated Docker Images Are a Real Problem
Before diving into solutions, let's acknowledge why image size matters more than ever in 2026:
- Security surface: Every binary, library, and shell utility in your image is a potential CVE. Smaller images = fewer vulnerabilities.
- Pull and startup time: A 1GB image on a cold-start Kubernetes node can add 30+ seconds to your deployment.
- Storage costs: When you're running thousands of pods across multiple regions, every megabyte compounds.
- Developer experience: Slow builds mean slow feedback loops.
The good news? Most of the bloat is unnecessary. Your application probably only needs 50-100MB to run. The rest is build tooling, package managers, and operating system utilities you'll never use in production.
The Foundation: Docker Multi-Stage Builds
Multi-stage builds are the single most impactful technique for producing lean production docker images. The concept is simple: use one stage to build your application (with all the compilers, dev dependencies, and tooling), then copy only the compiled artifacts to a minimal runtime stage.
Before: The Bloated Single-Stage Build
Here's the typical Node.js Dockerfile I see from clients:
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]
This image is around 1.2GB. Why? Because node:20 includes npm, yarn, git, curl, and a full Debian userspace. None of that is needed at runtime.
After: Multi-Stage Magic
# syntax=docker/dockerfile:1.7
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production=false
COPY . .
RUN npm run build && npm prune --production
# Stage 2: Runtime
FROM node:20-alpine AS runtime
WORKDIR /app
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package.json ./package.json
USER nodejs
EXPOSE 3000
CMD ["node", "dist/server.js"]
Just this change drops the image from ~1.2GB to ~180MB. The runtime stage only contains the production node_modules, the compiled output, and a non-root user for security.
Python Multi-Stage Example
Python images are notorious for ballooning. Here's how I handle Flask and FastAPI applications:
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential gcc libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
FROM python:3.12-slim AS runtime
WORKDIR /app
RUN useradd --create-home --shell /bin/bash app
COPY --from=builder /wheels /wheels
COPY requirements.txt .
RUN pip install --no-cache --no-index --find-links=/wheels /wheels/*.whl \
&& rm -rf /wheels
COPY --chown=app:app . .
USER app
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:server"]
This technique—building wheels in one stage and installing them in another—can cut Python images from 1GB+ down to ~150MB.
Distroless Containers: The Ultimate Minimalist Approach
When you really want to push the envelope, distroless containers are the answer. Google's distroless project provides base images containing only your application and its runtime dependencies—no shell, no package manager, no OS utilities.
What Makes Distroless Special
A typical gcr.io/distroless/nodejs20-debian12 image is around 120MB versus 350MB+ for node:20-slim. But the real win is the attack surface: distroless images typically have zero CVEs because they contain almost nothing to attack.
Distroless for Node.js
# syntax=docker/dockerfile:1.7
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["dist/server.js"]
The :nonroot tag is critical for production—never run as root. Final image size? Around 95MB.
Distroless for Python
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
FROM gcr.io/distroless/python3-debian12:nonroot
WORKDIR /app
COPY --from=builder /root/.local/lib/python3.12/site-packages /app/lib
COPY --from=builder /root/.local/bin /app/bin
COPY . .
ENV PYTHONPATH=/app/lib
CMD ["app.main:run"]
A quick warning: debugging distroless containers is harder because there's no shell. I always keep a debug variant image around for troubleshooting:
FROM gcr.io/distroless/nodejs20-debian12:debug-nonroot AS debug
# Use this for kubectl exec troubleshooting
BuildKit Caching: The Hidden Performance Multiplier
Even with multi-stage builds, you can still waste enormous amounts of time rebuilding dependencies on every code change. BuildKit caching solves this elegantly through cache mounts and build cache reuse.
Cache Mounts: Persistent Dependency Layers
The killer feature of BuildKit is the RUN --mount=type=cache directive. It lets you persist directories like ~/.npm or /root/.cache/pip across builds without committing them to the image.
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --prefer-offline --no-audit
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
The first build takes 90 seconds. Subsequent builds with no dependency changes? Under 3 seconds. The npm cache survives between builds but never enters the final image.
Python Cache Mounts
FROM python:3.12-slim AS builder
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --user -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "main.py"]
BuildKit Registry Cache
For CI/CD pipelines, push the BuildKit cache to your registry:
docker buildx build \
--cache-from=type=registry,ref=ghcr.io/myorg/myapp:cache \
--cache-to=type=registry,ref=ghcr.io/myorg/myapp:cache,mode=max \
--tag ghcr.io/myorg/myapp:latest \
--push \
.
The mode=max flag caches even intermediate layers, giving you near-instant builds across your entire CI fleet.
Combining All Three Techniques
The real magic happens when you stack these approaches. Here's a production-ready Node.js Dockerfile that combines every technique we've covered:
# syntax=docker/dockerfile:1.7
###################
# Build Stage
###################
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
--mount=type=cache,target=/root/.npm-cache \
npm ci --prefer-offline
###################
# Build Stage
###################
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
###################
# Production Runtime
###################
FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=builder --chown=nonroot:nonroot /app/dist ./dist
COPY --from=builder --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=builder --chown=nonroot:nonroot /app/package.json ./package.json
USER nonroot
EXPOSE 3000
ENV NODE_ENV=production
CMD ["dist/server.js"]
Final size: ~78MB. Original size: ~1.4GB. That's a 94% reduction.
Real-World Results: Before and After
Here's a comparison of images I've optimized for clients in 2026:
| Application | Original | Optimized | Reduction | |-------------|----------|-----------|-----------| | Node.js API | 1.4GB | 78MB | 94% | | Python ML Service | 2.1GB | 145MB | 93% | | Go Microservice | 850MB | 12MB | 99% | | Next.js Frontend | 1.1GB | 95MB | 91% |
The deployment speedup is dramatic. One client went from 4-minute cold starts to 22-second cold starts, slashing their Kubernetes autoscaling lag by 80%.
Best Practices I Always Follow
After optimizing hundreds of Dockerfiles, these are the rules I never break:
- Always use a
.dockerignorefile to prevent local files,.git, andnode_modulesfrom leaking into your build context. - Pin your base image versions to specific digests, not just tags.
node:20-alpinecan change behavior overnight. - Run as non-root in every production container. The
:nonrootvariants of distroless make this trivial. - Use
COPY --chowninstead of separatechowncommands—it saves a layer. - Combine RUN commands with
&&to minimize layers, but don't go overboard—readability matters. - Leverage BuildKit's
--mount=type=bindfor cache-heavy operations like monorepo builds.
Common Pitfalls to Avoid
- Don't use
:latesttags in production. Ever. You lose reproducibility. - Don't try to debug distroless containers with
docker exec. Use the debug variant instead. - Don't skip the
.dockerignore. A 2GBnode_modulesdirectory in your build context will make every build agonizingly slow. - Don't forget to test your optimized image. Multi-stage builds can accidentally exclude files your app needs at runtime.
Conclusion
Docker image optimization isn't a one-time task—it's a discipline. The combination of docker multi-stage builds, distroless containers, and buildkit caching gives you a framework to ship images that are 90%+ smaller, more secure, and faster to deploy than the bloated defaults.
In 2026, with container costs under constant scrutiny and security threats evolving daily, there's no excuse for shipping a 1GB image when a properly optimized version is under 100MB. Start with multi-stage builds, graduate to distroless, and layer in BuildKit caching for maximum impact.
If you're struggling with bloated Docker images in your CI/CD pipeline, I help teams audit and optimize their containerization strategy. Let's talk about squeezing every byte out of your deployments.