Cloud & Infrastructure · Container Engineering
Docker Image Optimization in 2026: Multi-Stage Builds and the Sizes That Actually Matter
A bloated Docker image costs you in pull times, storage fees, and attack surface. Here's how to build images that are small, fast to rebuild, and genuinely production-ready.
Anurag Verma
7 min read
Sponsored
A Node.js app with a standard node:latest base image plus its dependencies lands around 1.5GB. The actual application code is 5MB. Nobody intended that ratio, but it shows up constantly, and most teams live with it until something forces a change: a slow deployment pipeline, a surprise ECR bill, or a security scanner returning 200 vulnerabilities from packages the app never loads.
Getting a production Docker image right isn’t complicated. The principles haven’t changed much, but the tooling and base image ecosystem have, and a few techniques that weren’t common practice a few years ago are now table stakes.
The Problem With Default Images
Most Dockerfiles start from convenience images like node:20, python:3.12, or golang:1.22. These are full Debian or Ubuntu installations with compilers, package managers, build tools, and a few hundred packages you’ll never use. They exist for developer convenience during local development, not for production.
For production, you want:
- The runtime only (no compiler, no build tools, no package manager)
- A minimal OS layer or no OS layer at all
- No credentials, SSH keys, or build-time secrets in any layer
Multi-stage builds are how you get the build environment and the runtime environment in the same Dockerfile without including the build environment in the final image.
Multi-Stage Builds: The Pattern
# Stage 1: Build
FROM node:22-alpine AS builder
WORKDIR /app
# Copy dependency files first — they change less often than source
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:22-alpine AS production
WORKDIR /app
# Only copy what's needed to run
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
# Don't run as root
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
The builder stage includes dev dependencies and runs the build. The production stage starts fresh and only installs production dependencies, then copies the compiled output. Build tools never reach the final image.
For a typical TypeScript API, this takes the image from ~1.2GB to ~200MB, and that’s before switching to a slimmer base.
Choosing the Right Base Image
For Node.js:
| Base | Size | Use case |
|---|---|---|
node:22 | ~1.1GB | Local dev only |
node:22-slim | ~240MB | Production, needs some Linux tools |
node:22-alpine | ~60MB | Production, minimal dependencies |
gcr.io/distroless/nodejs22-debian12 | ~130MB | Security-focused, no shell |
Alpine images are smaller but can cause issues with native modules that expect glibc (Alpine uses musl). Most pure-JS packages work fine; native add-ons sometimes don’t. Test before committing to Alpine in production.
Distroless images from Google contain only the runtime and its dependencies: no shell, no package manager, no utilities. This reduces attack surface significantly. The downside: you can’t docker exec into a running container to debug, because there’s no shell to exec. Use them in production environments where you have proper observability in place.
For Python:
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.12-slim AS production
WORKDIR /app
COPY --from=builder /install /usr/local
COPY src/ ./src/
USER 1000
CMD ["python", "-m", "src.server"]
Using --prefix=/install in the build stage lets you copy only the installed packages to the final image cleanly.
For Go, the story is simpler: compile a static binary and use scratch or distroless/static:
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o server ./cmd/server
FROM gcr.io/distroless/static-debian12 AS production
COPY --from=builder /app/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]
A Go service compiled this way ends up around 10-20MB for the image. No OS packages, no runtime, just the binary and the distroless base layers.
Layer Caching: The Performance Wins
Docker caches layers. If a layer hasn’t changed since the last build, it reuses the cached version. The order of instructions in your Dockerfile determines how well caching works.
The most common mistake: copying the entire source tree before installing dependencies.
# Bad: invalidates dependency cache on every source change
COPY . .
RUN npm ci
# Good: dependency layer only rebuilds when package.json changes
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
Order instructions from least-to-most frequently changing:
- Base image declaration
- System package installs
- Dependency manifests + dependency install
- Application source code
- Build commands
With this ordering, changing a single source file only invalidates the COPY . . layer and everything after it. Dependency installation is cached.
BuildKit Features Worth Using
BuildKit is the default build engine in modern Docker and enables several optimizations:
Parallel stages. If your Dockerfile has independent stages (e.g., a test stage and a build stage that both depend on a base stage), BuildKit runs them in parallel.
Cache mounts for package managers. Instead of discarding the npm or pip cache between builds, you can mount a persistent cache:
RUN --mount=type=cache,target=/root/.npm \
npm ci
This keeps the npm cache across builds on the same machine, so packages that haven’t changed don’t get re-downloaded. In CI, this requires a cache volume or BuildKit cache export/import support from your CI system.
Secret mounts. Build-time secrets like private registry tokens should never be in a layer:
RUN --mount=type=secret,id=npm_token \
echo "//registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token)" > ~/.npmrc \
&& npm ci \
&& rm ~/.npmrc
The secret is available during the RUN command and never written to any image layer.
Checking What’s in Your Image
After building, inspect the layer sizes:
docker image history <image-name> --no-trunc
Or use dive for an interactive layer explorer:
# Install dive
brew install dive # macOS
# or
docker run --rm -it \
-v /var/run/docker.sock:/var/run/docker.sock \
wagoodman/dive:latest <image-name>
Dive shows you exactly what each layer adds and removes, color-coded by file state. It’s the fastest way to find that you accidentally copied a .git directory or left a 200MB test fixture in the build stage.
.dockerignore Is Not Optional
Without a .dockerignore, every COPY . . sends your entire build context to the Docker daemon, including node_modules, .git, build artifacts, and anything else in the directory. This slows down builds and can accidentally include files you don’t want in the image.
A typical .dockerignore:
.git
.gitignore
node_modules
npm-debug.log
dist
coverage
.env*
*.md
Dockerfile*
docker-compose*
.DS_Store
The build context should be as small as possible. Only files that the Dockerfile actually needs should cross the boundary.
Scanning Before Pushing
A small image with outdated base packages still carries vulnerabilities. Run a vulnerability scan as part of your build pipeline before pushing to production.
Trivy is the most common open-source option:
# Install
brew install trivy # macOS
# Scan an image
trivy image --severity HIGH,CRITICAL myapp:latest
Snyk integrates with CI pipelines and has a UI for tracking vulnerabilities across repositories. Both are worth using: Trivy for fast local checks, Snyk (or similar) for automated pipeline gates.
The fix for most base image vulnerabilities is just updating the base tag. Pin to a specific minor version rather than latest (to get reproducible builds), but rebuild regularly:
# Pinned but not frozen forever
FROM node:22.4-alpine3.20 AS production
Set a reminder or automated PR to bump base image tags monthly. Most vulnerability findings in production Docker images are unfixed base packages from images that haven’t been rebuilt in months.
The Numbers That Matter
The difference between an optimized image and a default one shows up in:
CI pipeline time. Pulling a 1.5GB image on every CI run adds minutes. Pulling a 200MB image is seconds. On busy pipelines with many concurrent runs, this cost is real.
Cold start time in ECS/K8s. When a container needs to start on a new node, the image has to be pulled. Smaller images start faster.
Container registry costs. ECR charges for storage per GB-month. Storing 20 versions of a 1.5GB image costs 30x what storing the same 20 versions of a 50MB image costs.
Attack surface. Fewer packages in the runtime image means fewer CVEs. A distroless image with no shell eliminates an entire category of post-exploitation moves.
None of these are blockers during development. They all become real in production at scale. Getting the Dockerfile right early is less painful than optimizing under pressure.
Sponsored
More from this category
More from Cloud & Infrastructure
Dev Containers: Reproducible Development Environments in 2026
Cloudflare R2 vs AWS S3 in 2026: The Storage Decision for Developer Teams
Error Tracking in 2026: What Sentry Catches That Your Logs Don't
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored