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.
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: ~180MBTrade-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 devBenefit: 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:latestMulti-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 productionMeasuring 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/distTypical benchmarks
| Application | Before (monolithic) | After (multi-stage) | Reduction |
|---|---|---|---|
| Node.js + TypeScript | 850MB | 180MB | 79% |
| Go application | 900MB | 12MB | 99% |
| Python (FastAPI) | 1.2GB | 350MB | 71% |
| React + Nginx | 700MB | 25MB | 96% |
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 ciSolution:
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 installSolution:
dockerfile# GOOD: Remove cache after install
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && \
npm cache clean --forceAnti-pattern 3: DevDependencies in production
dockerfile# BAD: Installs everything, including devDependencies
FROM node:18-alpine
COPY package*.json ./
RUN npm installSolution:
dockerfile# GOOD: Install only production dependencies
FROM node:18-alpine
COPY package*.json ./
RUN npm ci --only=productionCI/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=1Conclusion
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.