Docker Image Optimization

Shrink Docker images and speed up builds with slim base images, layer ordering, multi-stage builds, .dockerignore, and BuildKit cache mounts.

Large images slow down builds, pushes, pulls, and deployments, and they carry more packages that can contain vulnerabilities. Optimization has two goals: a small final image and fast, cache-friendly builds. Most gains come from a handful of habits: choosing a lean base image, ordering layers sensibly, excluding unnecessary files, and not shipping build tools.

Measure First

docker images my-app
# REPOSITORY   TAG   SIZE
# my-app       1.0   1.1GB

docker history my-app:1.0
# shows the size added by each layer, so you can find what is large

docker system df -v      # disk usage by image and layer sharing

Inspecting docker history tells you which instruction added the bulk, so you can optimize the right thing.

Choose a Smaller Base Image

Base type Example Notes
Full OS image ubuntu, debian Largest; includes many tools you do not need
Slim variant python:3.12-slim, node:20-slim Debian-based with fewer packages; good compatibility
Alpine variant node:20-alpine, python:3.12-alpine Very small, uses musl libc and apk; some binary packages need extra work
Distroless gcr.io/distroless/java21-debian12 No shell or package manager; minimal attack surface
scratch FROM scratch Empty; for static binaries such as Go

Alpine is not always the best choice. Because it uses musl instead of glibc, some native dependencies and performance-sensitive workloads behave differently, so test your application. For Java, a JRE image such as eclipse-temurin:21-jre is typically a sensible default.

Order Layers for Caching

Put things that change rarely at the top and things that change often at the bottom:

FROM node:20-slim
WORKDIR /app

# 1. Dependency manifests (change rarely)
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# 2. Application source (changes constantly)
COPY . .

CMD ["node", "server.js"]

If only source files change, Docker reuses the cached npm ci layer and the rebuild takes seconds.

Clean Up in the Same Layer

Each layer is additive. Deleting a file in a later RUN does not reduce the image size, because the file still exists in an earlier layer. Clean up in the same instruction that creates the mess:

# Bad: the package lists stay in the first layer
RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# Good
RUN apt-get update \
    && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

For Python, use pip install --no-cache-dir. For Alpine, use apk add --no-cache.

Use Multi-Stage Builds

Build in one stage and copy only the output into a slim runtime stage. This removes compilers, package manager caches, and source code from the final image.

FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
RUN mvn -B -q dependency:go-offline
COPY src ./src
RUN mvn -B -DskipTests package

FROM eclipse-temurin:21-jre
COPY --from=build /workspace/target/*.jar /app/app.jar
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

Use .dockerignore

A smaller build context makes builds faster and prevents unwanted files from entering layers:

.git
node_modules
target
build
dist
*.log
.env
.idea
.vscode

BuildKit Cache Mounts

BuildKit can keep a package manager's cache between builds without storing it in the image:

# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

RUN --mount=type=cache,target=/root/.m2 \
    mvn -B -DskipTests package

BuildKit is the default builder in current Docker Engine and Docker Desktop.

Install Only What You Need

  • Use --omit=dev (npm), --no-install-recommends (apt), or production-only dependency sets.
  • Do not install editors, debuggers, or curl unless the running container needs them.
  • Combine related packages into one RUN so the package index is fetched once.

Inspecting Layers Further

docker image inspect my-app:1.0 --format '{{.Size}}'
docker build --no-cache -t my-app:test .   # force a full rebuild when diagnosing cache issues

Third-party tools such as dive can browse layers interactively, but the built-in docker history is enough to start.

Common Mistakes

  • Copying the entire context before installing dependencies. Any file change busts the dependency cache.
  • Deleting files in a later layer to "shrink" the image. The earlier layer still contains them.
  • Choosing Alpine blindly. It can cause compatibility or performance problems with glibc-dependent software.
  • Leaving build tools and source in the runtime image. Use multi-stage builds.
  • Using ADD for local files or skipping .dockerignore. It enlarges the context and can pull in unexpected content.

Best Practices

  • Start from the smallest base image your application runs reliably on, with a pinned tag.
  • Copy dependency manifests first, install, then copy source.
  • Merge install and cleanup commands in a single RUN.
  • Use multi-stage builds and a thorough .dockerignore.
  • Track image size over time in CI so regressions are noticed.

Interview Questions

1. How do you reduce the size of a Docker image?

Use a smaller base image (slim, Alpine, distroless, or scratch), a multi-stage build so only runtime artifacts ship, a .dockerignore, minimal package installs with cleanup in the same layer, and avoid copying unnecessary files.

2. How does Docker layer caching work, and how do you take advantage of it?

Docker reuses a layer if the instruction and its inputs are unchanged. Once a layer changes, all following layers rebuild. Order instructions from least to most frequently changing, for example dependency files and install before copying source.

3. Why doesn't deleting a file in a later layer reduce image size?

Layers are immutable and additive. A delete is recorded as a whiteout in the new layer, but the original data remains in the layer that created it. Cleanup must happen in the same RUN that created the files, or the files must be excluded using a multi-stage build.

4. What is the difference between Alpine and slim images?

Alpine is based on musl libc and apk, producing very small images but with possible compatibility issues. Slim images are Debian-based with unneeded packages removed, slightly larger but using glibc and more widely compatible.

5. What are distroless images?

Images that contain only your application and its runtime dependencies, with no shell or package manager. They have a smaller attack surface, but debugging requires alternative techniques such as a debug variant or an ephemeral debug container.

6. What are BuildKit cache mounts?

A RUN --mount=type=cache,target=<dir> instruction gives the command a persistent cache directory on the build host, such as /root/.m2 or the pip cache. It speeds up repeated builds without adding the cache to the image.

Summary

  • Measure with docker history and optimize the largest contributors.
  • Choose lean base images, copy dependency files before source, and clean up within the same layer.
  • Multi-stage builds, .dockerignore, and BuildKit cache mounts provide the biggest wins.
  • Smaller images pull faster, deploy faster, and expose fewer vulnerabilities.

Continue Learning

Previous: Docker Security | Next: Docker Registry

Was this guide helpful?

CodeWithVenu is free to read, with no paywall. If this guide saved you time or helped you prepare, a small voluntary contribution helps me write more practical tutorials, architecture guides, and interview-prep content.