Skip to content

DevOps

Slimming Docker Images by 90%: Multi-Stage Builds, Distroless, and BuildKit Caching in 2026

August 25, 202612 min readRasel Hossain
Slimming Docker Images by 90%: Multi-Stage Builds, Distroless, and BuildKit Caching in 2026

Quick answer

Slim Docker images by 90% using multi-stage builds to separate compilation from runtime, distroless base images to eliminate OS bloat, and BuildKit cache mounts to speed up builds without bloating images. Most applications drop from over one gigabyte to under 100 megabytes.

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.

slimming docker images - Image 2

Why Bloated Docker Images Are a Real Problem

Before diving into solutions, let's acknowledge why image size matters more than ever in 2026:

slimming docker images - Image 3

  • 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:

  1. Always use a .dockerignore file to prevent local files, .git, and node_modules from leaking into your build context.
  2. Pin your base image versions to specific digests, not just tags. node:20-alpine can change behavior overnight.
  3. Run as non-root in every production container. The :nonroot variants of distroless make this trivial.
  4. Use COPY --chown instead of separate chown commands—it saves a layer.
  5. Combine RUN commands with && to minimize layers, but don't go overboard—readability matters.
  6. Leverage BuildKit's --mount=type=bind for cache-heavy operations like monorepo builds.

Common Pitfalls to Avoid

  • Don't use :latest tags 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 2GB node_modules directory 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.

#DevOps#Docker#Container Optimization#BuildKit#Distroless

How to do it

  1. 1

    Convert to a multi-stage Dockerfile

    Refactor your existing single-stage Dockerfile to use multiple FROM statements. Create a builder stage that includes your full toolchain, compiles your application, and installs all dependencies. Then create a minimal runtime stage that copies only the compiled artifacts and production dependencies. Use the Alpine variants of official images to start dropping size immediately.

  2. 2

    Switch to a distroless or minimal base image

    Replace your runtime base image with a distroless variant (gcr.io/distroless/) or a slim Alpine image. For Node.js use gcr.io/distroless/nodejs20-debian12:nonroot, for Python use gcr.io/distroless/python3-debian12:nonroot. Always use the :nonroot tag for security and ensure your CMD uses exec form JSON syntax since there's no shell in distroless images.

  3. 3

    Enable BuildKit and add cache mounts

    BuildKit is enabled by default in Docker 23+. Add the syntax directive at the top of your Dockerfile (# syntax=docker/dockerfile:1.7) and add RUN --mount=type=cache directives for your package manager caches. For npm use target=/root/.npm, for pip use target=/root/.cache/pip, for apt use target=/var/cache/apt. This persists caches between builds without bloating your final image.

  4. 4

    Configure registry-based cache for CI/CD

    In your CI/CD pipeline, use docker buildx with --cache-from and --cache-to flags pointing to your container registry. Use mode=max to cache all intermediate layers. This allows your entire CI fleet to share build cache, reducing build times from minutes to seconds even for clean builds from scratch.

  5. 5

    Verify image size and run security scans

    After implementing these changes, run docker images to verify the size reduction. Use docker scan or integrate Trivy into your pipeline to compare vulnerability counts before and after. A well-optimized production image should be under 150MB for most Node.js and Python applications, with significantly fewer CVEs than the bloated original.

Frequently asked questions

What is a multi-stage Docker build and why does it reduce image size?

A multi-stage Docker build uses multiple FROM statements in a single Dockerfile to separate the build environment from the runtime environment. You compile your application in one stage with all the necessary build tools (compilers, dev dependencies, build utilities), then copy only the compiled artifacts to a minimal runtime stage. This eliminates the need to ship compilers, package managers, and build tools in your production image, typically reducing size by 60-80%.

Are distroless containers safe for production use?

Yes, distroless containers are excellent for production and are widely used by Google, Microsoft, and other major companies. They contain only your application runtime and its dependencies—no shell, package manager, or OS utilities—drastically reducing the attack surface. The tradeoff is harder debugging since you can't shell into the container. I recommend keeping a debug variant image alongside your production distroless image for troubleshooting.

How does BuildKit caching work and is it worth enabling?

BuildKit is Docker's next-generation build engine that introduces advanced caching features like cache mounts (RUN --mount=type=cache) and registry-based cache sharing. Cache mounts let you persist dependency directories between builds without including them in the final image. This can reduce build times by 70-90% for projects with large dependency trees. It's absolutely worth enabling—BuildKit is now the default in Docker 23+ and requires no special configuration.

How much can I realistically shrink my Docker image?

Most applications can achieve 80-95% size reduction using the techniques in this guide. A typical Node.js application goes from 1GB-1.4GB down to 80-150MB. Python applications often see 90%+ reductions, going from 1.5-2GB down to 100-200MB. Go applications can reach 99% reduction (under 20MB) because Go statically compiles to a single binary. The exact numbers depend on your dependencies and base image choice.

Do I need to change my application code to use distroless images?

Generally no—distroless images work with existing applications without code changes. They include the same language runtimes (Node.js, Python, Java) as standard images. The main requirement is that your CMD or ENTRYPOINT uses the exec form (JSON array syntax) rather than shell form, because there's no shell to interpret commands. For example, use CMD ["node", "server.js"] instead of CMD node server.js.

More articles

Related reading from the same areas — practical notes on shipping software.

View all articles

Liked the article?

Have a similar problem in your business? Let's talk about building the fix.

Start a project