Skip to content
Codeloom
DevOps

Container Image Optimization Techniques

Reduce Docker image size and build time with multi-stage builds, layer caching, distroless bases, and practical Dockerfile patterns.

·5 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • Why smaller images matter for security and speed
  • Multi-stage build patterns that actually work
  • How to choose the right base image
  • Layer ordering for optimal cache hits
  • Distroless and scratch images for production

Prerequisites

None — this post is self-contained.

A 1.2 GB Docker image is not just annoying. It takes longer to push, longer to pull, longer to scan, and exposes a larger attack surface. Every extra package in the image is another potential CVE in your next Trivy report. Optimizing container images is one of the highest-leverage improvements you can make to your CI/CD pipeline and production runtime.

Start with the Right Base

The base image sets the floor for your image size. Here is a comparison for a simple Node.js application:

Base ImageSize
node:20~1.1 GB
node:20-slim~240 MB
node:20-alpine~140 MB
gcr.io/distroless/nodejs20~130 MB

Alpine uses musl libc instead of glibc, which occasionally causes compatibility issues with native modules. The slim variant uses Debian with most extras removed. Distroless images contain only the runtime and your application, with no shell, no package manager, and no utilities an attacker could exploit.

Pick the smallest base that works for your application. For Go, Rust, or any statically compiled language, you can go all the way to scratch:

FROM scratch
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]

Multi-Stage Builds

Multi-stage builds let you use a full development environment for compilation but ship only the artifacts. A Node.js example:

# Stage 1: Install dependencies and build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production=false
COPY . .
RUN npm run build

# Stage 2: Production image
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

The builder stage has TypeScript, dev dependencies, and build tools. The production stage only has the compiled output and production node_modules. Everything else is discarded.

For Go, the difference is even more dramatic:

FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /server ./cmd/server

FROM gcr.io/distroless/static-debian12
COPY --from=builder /server /server
ENTRYPOINT ["/server"]

The builder stage is around 800 MB. The final image is under 15 MB because it contains only the statically linked binary and a minimal runtime.

Layer Ordering for Cache Efficiency

Docker caches layers sequentially. When a layer changes, every layer after it is rebuilt. Order your Dockerfile instructions from least-frequently changed to most-frequently changed:

# Rarely changes - cached almost always
FROM node:20-alpine
WORKDIR /app

# Changes when dependencies change
COPY package.json package-lock.json ./
RUN npm ci

# Changes on every code commit
COPY . .
RUN npm run build

If you put COPY . . before RUN npm ci, every code change would reinstall all dependencies. With the correct order, dependency installation is cached until package.json actually changes, saving minutes on every build.

Reducing Layer Count and Size

Each RUN instruction creates a layer. Combine related commands to avoid intermediate layers that bloat the image:

# Bad: three layers, apt cache persisted
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# Good: one layer, cache cleaned in the same step
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl && \
    rm -rf /var/lib/apt/lists/*

The --no-install-recommends flag prevents apt from pulling in suggested packages you do not need. Cleaning the apt cache in the same RUN instruction ensures the cache never persists in a layer.

Using .dockerignore

A missing .dockerignore copies your entire working directory into the build context, including .git, node_modules, test fixtures, and local configuration:

.git
node_modules
*.md
.env*
coverage
.nyc_output
dist
Dockerfile
docker-compose*.yml

A proper .dockerignore speeds up the build context transfer and prevents sensitive files from ending up in the image.

Production Hardening

Beyond size, optimize for security:

FROM node:20-alpine

# Create a non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules

# Drop to non-root
USER appuser

# Use dumb-init to handle signals properly
RUN apk add --no-cache dumb-init
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/index.js"]

Running as a non-root user prevents container escape exploits from gaining host privileges. Using dumb-init or tini ensures your process handles SIGTERM correctly for graceful shutdown in Kubernetes.

Measuring Results

Use docker images to check sizes, and dive to inspect layers interactively:

# Check image size
docker images myapp

# Inspect layers with dive
dive myapp:latest

Dive shows you each layer’s contents, highlights wasted space, and gives you an efficiency score. It is the single best tool for understanding why an image is larger than expected.

Set up a CI check that fails if the image exceeds a size threshold:

IMAGE_SIZE=$(docker inspect myapp:latest \
  --format='{{.Size}}')
MAX_SIZE=$((200 * 1024 * 1024))  # 200 MB

if [ "$IMAGE_SIZE" -gt "$MAX_SIZE" ]; then
  echo "Image size ${IMAGE_SIZE} exceeds limit"
  exit 1
fi

Wrap-up

Smaller images build faster, deploy faster, pull faster, and expose fewer vulnerabilities. Start with the right base image, use multi-stage builds to separate build-time from runtime, order layers for cache efficiency, and drop to a non-root user. The effort is minimal and the benefits compound across every build and deployment.