Cloud and platform

Docker BuildKit with Multi-Stage: Reducing Production Image Size

How BuildKit and multi-stage builds transform bloated containers into efficient production images, reducing storage costs and accelerating deploys.

3/17/20266 min readCloud
Docker BuildKit with Multi-Stage: Reducing Production Image Size

Executive summary

How BuildKit and multi-stage builds transform bloated containers into efficient production images, reducing storage costs and accelerating deploys.

Last updated: 3/17/2026

The problem of bloated containers

A production Node.js container that should weigh 50MB can easily end up at 500MB+, 1GB or more. This happens when build dependencies, development tools, caches, and temporary artifacts are included in the final image.

Large images mean:

  • Slower deploys: 500MB takes 10x longer to pull than 50MB
  • Higher costs: registry storage costs money
  • Larger attack surface: more installed packages = more vulnerabilities
  • Less efficient cache: small changes invalidate large layers

Multi-stage builds with BuildKit solve this by allowing you to use multiple Dockerfiles in a single build, copying only necessary artifacts to the final image.

BuildKit: The new build engine

BuildKit is Docker's next-generation build engine, enabled by default since Docker 18.09. It offers:

  • Parallel stage execution: independent stages run simultaneously
  • Smart caching: cache per instruction, not entire layer
  • Build secrets: pass secrets without including them in final image
  • Inline caching: remote cache for CI/CD builds

Enable BuildKit:

bash# Linux/Mac
export DOCKER_BUILDKIT=1

# Windows
$env:DOCKER_BUILDKIT=1

# Or configure globally in ~/.docker/config.json
{
  "features": {
    "buildkit": true
  }
}

Multi-stage: The fundamental pattern

Basic example: Node.js

dockerfile# Stage 1: Build
FROM node:18-alpine AS builder

WORKDIR /app

# Copy package files first for dependency caching
COPY package*.json ./

# Install all dependencies
RUN npm ci --only=production=false

# Copy source code
COPY . .

# Build application
RUN npm run build

# Stage 2: Production
FROM node:18-alpine AS production

WORKDIR /app

# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001

# Copy only production files from previous stage
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist

# Switch to non-root user
USER nodejs

EXPOSE 3000

CMD ["node", "dist/index.js"]

Result: final image without devDependencies, without TypeScript source code, without build tools. Typical 60-80% reduction.

Example: Go

dockerfile# Stage 1: Build
FROM golang:1.21-alpine AS builder

WORKDIR /app

# Install build dependencies
RUN apk add --no-cache git ca-certificates

# Download dependencies (separate cache from code)
COPY go.mod go.sum ./
RUN go mod download

# Copy code and build
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o main .

# Stage 2: Production
FROM alpine:3.19

# Install SSL certificates
RUN apk --no-cache add ca-certificates

WORKDIR /root/

# Copy only compiled binary
COPY --from=builder /app/main .

# Use Alpine's ca-certificates
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

EXPOSE 8080

CMD ["./main"]

Result: static Go binary at ~10-15MB, without Go SDK, without tools.

Advanced optimization patterns

Isolated dependency caching

dockerfile# Isolate dependency installation for granular cache
FROM node:18-alpine AS deps

WORKDIR /app

COPY package*.json ./
RUN npm ci

# Build stage
FROM node:18-alpine AS builder

WORKDIR /app

# Copy dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
COPY . .

RUN npm run build

# Production stage
FROM node:18-alpine AS production

WORKDIR /app

# Copy only production dependencies
COPY --from=deps /app/package*.json ./
RUN npm prune --production

COPY --from=builder /app/dist ./dist

CMD ["node", "dist/index.js"]

Benefit: code changes don't invalidate npm ci cache, accelerating subsequent builds.

Multi-repository architecture

dockerfile# Frontend stage
FROM node:18-alpine AS frontend-builder

WORKDIR /app
COPY frontend/package*.json ./frontend/
RUN cd frontend && npm ci

COPY frontend/ ./frontend/
RUN cd frontend && npm run build

# Backend stage
FROM node:18-alpine AS backend-builder

WORKDIR /app
COPY backend/package*.json ./backend/
RUN cd backend && npm ci

COPY backend/ ./backend/
RUN cd backend && npm run build

# Production stage
FROM node:18-alpine

WORKDIR /app

# Copy artifacts from both repositories
COPY --from=frontend-builder /app/frontend/dist ./public
COPY --from=backend-builder /app/backend/dist ./dist
COPY --from=backend-builder /app/backend/package*.json ./
RUN npm prune --production

CMD ["node", "dist/index.js"]

Size reduction techniques

Alpine vs Slim vs Debian images

dockerfile# Debian (largest)
FROM node:18
# Size: ~900MB

# Debian Slim (balanced)
FROM node:18-slim
# Size: ~250MB

# Alpine (smallest)
FROM node:18-alpine
# Size: ~180MB

Trade-off: Alpine uses musl libc instead of glibc. Most applications work, but some native packages may have issues. Use Alpine when possible; Slim when you need glibc compatibility.

Multi-stage with distroless

dockerfile# Build stage
FROM golang:1.21 AS builder

WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o main .

# Production with distroless (Google)
FROM gcr.io/distroless/base-debian12

COPY --from=builder /app/main .

EXPOSE 8080

CMD ["./main"]

Benefit: distroless has no shell, package manager, or other tools. Minimal attack surface. Size: ~5-10MB.

Removing unnecessary files

dockerfile# In final stage, remove caches, logs and temp files
RUN npm cache clean --force && \
    rm -rf /tmp/* /var/tmp/* && \
    rm -rf /root/.npm /root/.cache && \
    find /app -name "*.log" -delete && \
    find /app -type d -name ".git" -prune -exec rm -rf {} +

# For Alpine
RUN rm -rf /var/cache/apk/*

Advanced BuildKit features

Inline caching for CI/CD

bash# First build: creates local cache
docker build \
  --build-arg BUILDKIT_INLINE_CACHE=1 \
  --cache-from myregistry.io/myapp:buildcache \
  -t myapp:latest \
  .

# Push cache
docker push myregistry.io/myapp:buildcache

# Subsequent builds: use remote cache
docker build \
  --build-arg BUILDKIT_INLINE_CACHE=1 \
  --cache-from myregistry.io/myapp:buildcache \
  --cache-to myregistry.io/myapp:buildcache \
  -t myapp:latest \
  .

Benefit: CI/CD builds can leverage cache from previous builds, reducing build time from minutes to seconds.

Build secrets without leakage

dockerfile# BuildKit secret syntax
RUN --mount=type=secret,id=aws,dst=/root/.aws/credentials \
    npm ci

# For builds that need private access tokens
RUN --mount=type=secret,id=github_token,dst=/tmp/token \
    npm config set //registry.npmjs.org/:_authToken $(cat /tmp/token)
bash# Pass secret via CLI (doesn't end up in image)
docker build \
  --secret id=aws,src=$HOME/.aws/credentials \
  --secret id=github_token,src=$GITHUB_TOKEN \
  -t myapp:latest \
  .

Benefit: credentials are accessible during build but never included in final image.

Build mounts for performance

dockerfile# Uses bind mount from host instead of COPY for development
FROM node:18-alpine AS development

WORKDIR /app

# Mount source code (read-only)
RUN --mount=type=bind,source=.,target=/app,readonly \
    npm run build

# For watch mode in development
RUN --mount=type=bind,source=.,target=/app \
    npm run dev

Benefit: during development, source code is mounted directly, avoiding Docker copies and caching.

Security best practices

Non-root user

dockerfileFROM node:18-alpine

WORKDIR /app

# Create user and group
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001

# Copy files with correct ownership
COPY --chown=nodejs:nodejs package*.json ./
RUN npm ci --only=production && \
    npm cache clean --force

COPY --chown=nodejs:nodejs . .

# Switch to non-root user
USER nodejs

EXPOSE 3000

CMD ["node", "index.js"]

Vulnerability scanning in build

bash# Use Trivy during build
FROM node:18-alpine AS security-scan

# Install Trivy
RUN apk add --no-cache curl

# Scan final image
COPY --from=production /app /app
RUN apk add --no-cache trivy && \
    trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:latest

Multi-stage with security scan

dockerfile# Production stage
FROM node:18-alpine AS production
# ... production config ...

# Security stage
FROM aquasec/trivy:latest AS security-scan

COPY --from=production / /app
RUN trivy image --exit-code 0 --severity HIGH,CRITICAL /app

# Final image passes through scan
FROM production

Measuring optimization impact

Before/after metrics

bash# Check image size
docker images myapp:latest --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"

# Layer history
docker history myapp:latest

# Layer content analysis
docker diff <container_id>

# Artifact sizes
docker run --rm myapp:latest du -sh /app/node_modules
docker run --rm myapp:latest du -sh /app/dist

Typical benchmarks

ApplicationBefore (monolithic)After (multi-stage)Reduction
Node.js + TypeScript850MB180MB79%
Go application900MB12MB99%
Python (FastAPI)1.2GB350MB71%
React + Nginx700MB25MB96%

Common anti-patterns

Anti-pattern 1: COPY at end of Dockerfile

dockerfile# BAD: Copies everything first, invalidates entire cache when any file changes
FROM node:18-alpine
WORKDIR /app
COPY . .
RUN npm ci

Solution:

dockerfile# GOOD: Isolate dependencies for cache
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .

Anti-pattern 2: Not removing npm cache

dockerfile# BAD: npm cache remains in image
FROM node:18-alpine
WORKDIR /app
COPY . .
RUN npm install

Solution:

dockerfile# GOOD: Remove cache after install
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && \
    npm cache clean --force

Anti-pattern 3: DevDependencies in production

dockerfile# BAD: Installs everything, including devDependencies
FROM node:18-alpine
COPY package*.json ./
RUN npm install

Solution:

dockerfile# GOOD: Install only production dependencies
FROM node:18-alpine
COPY package*.json ./
RUN npm ci --only=production

CI/CD integration

GitHub Actions

yamlname: Build and Push

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Cache Docker layers
        uses: actions/cache@v3
        with:
          path: /tmp/.buildx-cache
          key: ${{ runner.os }}-buildx-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-buildx-

      - name: Login to registry
        uses: docker/login-action@v3
        with:
          registry: ${{ secrets.REGISTRY_URL }}
          username: ${{ secrets.REGISTRY_USERNAME }}
          password: ${{ secrets.REGISTRY_PASSWORD }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ secrets.REGISTRY_URL }}/myapp:latest
          cache-from: type=local,src=/tmp/.buildx-cache
          cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
          build-args: |
            BUILDKIT_INLINE_CACHE=1

Conclusion

Multi-stage builds with BuildKit transform bloated containers into efficient production images. The technique is simple in concept — separate build environment from runtime environment — but requires discipline to implement consistently.

The impact is measurable: 60-99% reductions in image size, significantly faster deploys, lower storage costs, and reduced attack surface. For companies running hundreds or thousands of containers, these savings accumulate into significant numbers.

BuildKit adds features like inline caching and build secrets that make the process more secure and efficient, especially in CI/CD environments. Combining multi-stage builds with Alpine or distroless images creates small, secure, fast-to-deploy images.

The best part: the technique is language-agnostic. Node.js, Go, Python, Java, Ruby — all benefit from isolating build from production. The next step is not just implementing multi-stage builds, but establishing organizational patterns: Dockerfile templates, security baselines, and continuous optimization metrics.


Are your containers bloated and slow to deploy? Talk to Imperialis DevOps experts to optimize your container strategy, reduce costs, and accelerate deploys.

Sources

Related reading