Dockerfile Optimization 2025 — Smaller, Faster, More Secure Images

Sanjeev SharmaSanjeev Sharma
7 min read

Advertisement

Introduction

Why This Matters

A 1.5 GB Docker image is not just a storage problem. Every CI pipeline job that pulls it wastes minutes. Every Kubernetes node that cold-starts a pod incurs startup latency. Every vulnerability scanner flags dozens of CVEs in unused system libraries. Large, unoptimized images are a tax paid on every build, every deploy, and every security audit.

The good news is that most images can be reduced by 70–90% using techniques available in every version of Docker since 17.05. A Node.js API that starts at 1.1 GB can become 85 MB. A Python service at 900 MB can become 120 MB. These are not marginal improvements — they change the economics of your CI/CD pipeline and your container registry bills.

This guide covers every major Dockerfile optimization available in 2025, with before-and-after examples you can apply immediately.

Choose the Right Base Image

The base image is the dominant factor in final image size. Make this decision deliberately.

# Option 1: Full Debian — 1.1 GB — avoid in production
FROM node:20
 
# Option 2: Debian Slim — 240 MB — acceptable for development
FROM node:20-slim
 
# Option 3: Alpine — 175 MB — good default for most services
FROM node:20-alpine
 
# Option 4: Distroless — 165 MB — best for production security
FROM gcr.io/distroless/nodejs20-debian12

Distroless images contain only the runtime and your application — no shell, no package manager, no wget. This removes the tools attackers need to move laterally after a container escape.

Multi-Stage Builds

This is the single most impactful optimization for compiled or transpiled applications:

# ---- Stage 1: Build ----
FROM node:20-alpine AS builder
WORKDIR /app
 
COPY package*.json ./
RUN npm ci
 
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
 
# ---- Stage 2: Production ----
FROM node:20-alpine AS production
WORKDIR /app
 
# Only copy what is needed to run
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
 
COPY --from=builder /app/dist ./dist
 
# Non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
 
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s CMD wget -q --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/index.js"]

The production image has no TypeScript compiler, no webpack, no devDependencies — only the compiled JavaScript and production node_modules.

Go Multi-Stage Example

Go produces a single static binary, enabling an extremely small final image:

# Build stage
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="-s -w" -o server ./cmd/server
 
# Final stage — scratch has zero extra files
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

Result: a Go API in a 12 MB image.

Maximize Layer Cache Efficiency

Docker rebuilds all layers after the first cache miss. The rule: copy things that change rarely before things that change often.

# WRONG: src changes invalidate the npm install layer
FROM node:20-alpine
WORKDIR /app
COPY . .                          # cache miss on every src change
RUN npm ci --only=production      # reinstalls every time!
 
# CORRECT: dependencies cached separately
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./             # only changes when dependencies change
RUN npm ci --only=production      # cached unless package.json changes
COPY . .                          # source change only invalidates this layer

For Python:

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# requirements.txt rarely changes, so pip install is cached most builds
COPY . .

Combine RUN Instructions Intelligently

Every RUN instruction creates a new layer. Combine related steps, but keep unrelated steps separate to preserve cache granularity:

# Bad: separate layers = larger image, repeated apt overhead
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
RUN rm -rf /var/lib/apt/lists/*
 
# Good: combined, cache-cleaned in the same layer
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
      curl=8.5.0-r0 \
      git=1:2.43.0-1 && \
    rm -rf /var/lib/apt/lists/* && \
    apt-get clean

The --no-install-recommends flag alone can save 50–200 MB by excluding suggested packages.

Use BuildKit Features

Enable BuildKit for faster builds with better caching:

export DOCKER_BUILDKIT=1
docker build -t myapp .

Or use docker buildx build which enables BuildKit by default.

Mount Cache for Package Managers

BuildKit cache mounts persist package manager caches across builds without baking them into image layers:

# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --only=production
COPY . .
# Python with pip cache
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

Package downloads are cached on the build host. Second and subsequent builds skip the download entirely.

Minimize What Gets Copied

A proper .dockerignore file is as important as the Dockerfile itself:

# .dockerignore
node_modules
.git
.env
.env.*
.env.local
*.log
dist/
build/
coverage/
.nyc_output/
.DS_Store
.vscode/
.idea/
__pycache__/
*.pyc
*.pyo
Dockerfile*
docker-compose*
terraform/
.terraform/
*.tfstate

Without this, Docker sends your entire project (potentially hundreds of MB) as the build context before even starting.

COPY --link allows layers to be reordered without invalidating the cache. Useful for large static assets:

# syntax=docker/dockerfile:1
FROM node:20-alpine AS base
WORKDIR /app
 
FROM base AS deps
RUN --mount=type=cache,target=/root/.npm \
    --mount=type=bind,source=package.json,target=package.json \
    --mount=type=bind,source=package-lock.json,target=package-lock.json \
    npm ci --only=production
 
FROM base AS final
COPY --link --from=deps /app/node_modules ./node_modules
COPY --link . .
CMD ["node", "server.js"]

Specific Language Optimizations

Python

FROM python:3.12-slim AS builder
WORKDIR /app
 
# Install build deps in a separate layer
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc libpq-dev && rm -rf /var/lib/apt/lists/*
 
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
 
FROM python:3.12-slim
WORKDIR /app
# Copy only the installed packages from builder
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
CMD ["gunicorn", "app:app"]

Java / Spring Boot

FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY mvnw pom.xml ./
COPY .mvn .mvn
RUN ./mvnw dependency:go-offline -q
COPY src src
RUN ./mvnw package -DskipTests
 
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
RUN adduser -S -D -H -h /app appuser
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Inspect and Measure Image Layers

# Show layer sizes
docker history myapp:1.0
 
# Detailed JSON output
docker image inspect myapp:1.0
 
# Use dive for interactive layer analysis
docker run --rm -it \
  -v /var/run/docker.sock:/var/run/docker.sock \
  wagoodman/dive:latest myapp:1.0

Common Mistakes

  • Using ADD instead of COPY for local files — ADD has implicit behavior (URL fetching, tar extraction) that is confusing
  • Running apt-get update in a separate layer from apt-get install — the update layer gets cached stale
  • Not cleaning apt/yum/apk caches in the same RUN instruction — cache files remain in the layer permanently
  • Building production images without multi-stage builds — devDependencies and build tools bloat the final image
  • Not using .dockerignore — node_modules gets sent as build context (can be 500 MB)

Best Practices

  • Use FROM ... AS builder and FROM ... AS production multi-stage pattern for every non-trivial service
  • Pin base image versions to a digest (node:20.12.0-alpine3.19@sha256:abc123) for fully reproducible builds
  • Use COPY in exec form and avoid shell globbing when precision matters
  • Add LABEL org.opencontainers.image.source, version, and revision labels for traceability
  • Measure image size before and after changes with docker images — track it as a metric

Key Takeaways

  • Switching from node:20 to node:20-alpine reduces image size from ~1.1 GB to ~175 MB with zero code changes
  • Multi-stage builds are the most impactful single optimization for TypeScript, Go, Java, and Python apps
  • Copy dependency manifests before source code — one line change saves minutes per CI build via layer caching
  • --no-install-recommends on apt-get and --no-cache-dir on pip prevent package manager bloat in image layers
  • BuildKit cache mounts (--mount=type=cache) speed up repeated builds without bloating image layers
  • The dive tool visualizes layer contents and identifies wasted space interactively
  • Distroless base images eliminate the shell and package manager, removing the most commonly exploited attack surface
  • Measure image size as part of CI — unchecked growth in image size is a signal of Dockerfile regression

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading