Skip to content

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

Anurag Verma

7 min read

Docker Image Optimization in 2026: Multi-Stage Builds and the Sizes That Actually Matter

Sponsored

Share

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:

BaseSizeUse case
node:22~1.1GBLocal dev only
node:22-slim~240MBProduction, needs some Linux tools
node:22-alpine~60MBProduction, minimal dependencies
gcr.io/distroless/nodejs22-debian12~130MBSecurity-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:

  1. Base image declaration
  2. System package installs
  3. Dependency manifests + dependency install
  4. Application source code
  5. 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

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored