Dockerfile Optimization

Optimizing a Dockerfile is not a cosmetic exercise—it directly affects image size, build latency, registry bandwidth, deployment time, and the attack surface of running containers. You should revisit Dockerfile structure whenever CI time grows, image pull times increase, or a vulnerability scan adds hours to your pipeline. The techniques below are ordered by the problem the reader is trying to solve, not by Dockerfile instruction taxonomy.

Reducing Image Size

The most immediate lever is the final image footprint. Every extra megabyte in the base image propagates to every downstream environment.

  • Start from a minimal base. If your language runtime provides a distroless or Alpine variant, use it. FROM python:3.12-slim pulls roughly 120 MB; FROM python:3.12-alpine pulls under 40 MB. The choice depends on library compatibility—Alpine uses musl instead of glibc, which can break compiled extensions without apk add --no-cache gcc musl-dev, whereas slim retains glibc but strips documentation and locale data.
  • Prune package caches during the build. In any RUN apt-get update && apt-get install -y …, add rm -rf /var/lib/apt/lists/* in the same layer. This removes the index files that typically add 5–15 MB per package set without affecting the installed binaries.
  • Prefer COPY over ADD when extraction is not needed. ADD silently auto-detects remote URLs and tar.gz files, extracting them during build. If you only need to place a file or a tarball on the filesystem, COPY avoids the extra layer and the associated disk I/O. Use ADD only when the remote URL or automatic extraction is explicitly required.
  • Leverage multi-stage builds to separate build artifacts from the runtime image. Define an intermediate stage with a full build toolchain and copy only the produced binary or wheel into a clean base.

FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/app .

FROM gcr.io/distroless/base-debian12
COPY --from=builder /out/app /app/app
ENTRYPOINT ["/app/app"]

The first stage pulls the full Go toolchain; the second stage pulls only the distroless base, resulting in a final image that is typically under 30 MB.

Speeding Up Builds with Cache

Docker builds left‑to‑right, caching each RUN instruction. A single file change early in the file can invalidate every subsequent layer, forcing a full rebuild.

  • Order instructions from least to most volatile. Place commands that change infrequently at the top. A common pattern is to copy dependency manifests, install system packages, and download dependencies before any application source code.
# Anti‑pattern: source copy invalidates the dependency cache on every change
COPY . .
RUN pip install -r requirements.txt

# Recommended order: manifest copy → install → source copy
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

The second version caches the pip install layer as long as requirements.txt (and its transitive dependencies) remains unchanged. Adding or removing a single source file never forces a reinstall of the Python stack.

  • Use --mount=type=cache for frequently rebuilt paths. Docker 20.10+ supports mount caches that persist across builds on the same host. This is useful for toolchains that are expensive to download but rarely change.
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --no-cache-dir -r requirements.txt

The first build populates the cache at /root/.cache/pip on the host. Subsequent builds on that host reuse the cached directory, skipping the network download. Cache is per‑host and does not survive docker system prune or host reboot, but it eliminates redundant work during developer iteration.

  • Avoid --no-cache in production CI unless you truly need a fresh start. The flag forces every RUN to re‑execute, which maximizes CI time and registry traffic. Use it only when a build is known to be stuck or when you are debugging a caching anomaly.

Structuring Multi-Stage Builds

Multi-stage builds are the standard pattern for keeping runtime images lean while retaining a powerful build environment. The mechanics are simple: each FROM starts a new stage, and COPY --from=<name|stage> moves artifacts between them.

  • Name stages explicitly. Using AS builder (or any label) makes the copy target unambiguous and allows referencing the stage by name in subsequent FROM instructions.
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/package*.json ./
RUN npm ci --omit=dev --production
COPY --from=builder /app/dist ./dist
ENV NODE_ENV=production
CMD ["node", "dist/index.js"]

The first stage installs dev dependencies and builds assets. The second stage only copies the production‑ready output and a minimal Node runtime.

  • Be explicit about what you copy. Avoid COPY . . across stages unless you are certain every file in the build context is needed at runtime. Each copied file adds to the final image size and surface area.

Catching Problems Before They Reach Production

Automated checks in the CI pipeline prevent malformed or insecure images from being pushed.

  • Lint the Dockerfile itself. hadolint parses the Dockerfile against best‑practice rules and exits with a non‑zero status on violations. It is distributed as a binary and can be called from a CI step.
hadolint Dockerfile

If the file uses latest tags, runs apt-get upgrade in RUN, or copies files into /, hadolint will report them. Fixing these findings reduces the risk of unexpected version bumps or unnecessary privileges.

  • Scan the built image for known vulnerabilities. trivy is a standalone binary that queries OS and language package databases without requiring a running container.
trivy image --exit-code 1 --severity HIGH,CRITICAL myregistry/myapp:latest

The command exits 1 if any high or critical CVEs are found in the image layers. Integrating this check into a pull‑request pipeline blocks merges until vulnerabilities are remediated.

  • Pin base image tags. Using FROM ubuntu:22.04 is safe; using FROM ubuntu resolves to latest, which can silently change when the publisher pushes a new release. Pinning ensures reproducible builds and prevents “works on my machine” drift.

Sharp Edges and Gotchas

  • .dockerignore must exist and be in the build context root. Docker does not read .dockerignore from subdirectories. If the file is missing or placed elsewhere, every file in the build context—including temporary scripts, test fixtures, and Git history—is sent to the daemon and can appear in layers. A missing .dockerignore is one of the most common causes of unexpectedly large images.
  • ADD versus COPY confusion. ADD url.tar.gz extracts the tarball into the layer. ADD ./file.tar.gz does the same for local files. If you intend to add a tarball as a file (not extract it), COPY will leave the tarball intact, whereas ADD will extract it. Prefer COPY unless extraction is the explicit goal.
  • RUN non‑zero exit status. By default, if any command in a RUN line exits non‑zero, Docker stops building and returns an error. However, appending || true silently masks failures, producing an empty or partially populated layer that may cause runtime errors later. Always verify that RUN commands exit cleanly; use set -e or explicit error handling in shell scripts.
  • Environment variable leakage. ENV instructions persist across all subsequent stages unless explicitly unset. If a secret or development path is set early, it may end up in the final image. Use ARG for build‑only values and only ENV values that must be present at runtime.
  • --pull versus --no-pull. docker build --pull attempts to pull a newer base image index before building. In air‑gapped or rate‑limited environments, this can stall the build. docker build --no-pull uses the locally cached index, which is faster but may miss security updates in the base image. Choose based on your environment’s connectivity and security policy.
  • Layer squashing. docker build --squash collapses all layers into a single one, reducing image size for pushes. The flag is experimental as of Docker 25.0 and may not be available in all distributions. An alternative is to structure the Dockerfile so that only the final runtime layers are needed, achieving size reduction without the experimental flag.

Debugging Build Failures

When a build fails partway through, docker build --progress=plain prints each RUN command as it executes, and docker build --no-cache forces a clean slate if you suspect a stale cache entry is the culprit. For more detail, docker build --force-rm removes intermediate containers after each step, preventing disk fill from accumulated layers during iterative development.

When to Re‑evaluate

A Dockerfile should be treated as source code subject to the same review cycles. Add a Dockerfile linting step to every PR, pin base image tags, and run a vulnerability scan on every successful build. If CI time exceeds 10 minutes for a modest service, revisit the order of COPY and RUN instructions. If the final image is larger than 200 MB without a justified compiler or database engine, replace the base image with a distroless or alpine variant and restructure the multi‑stage build accordingly. These checks are low‑overhead, high‑impact, and fit naturally into existing CI pipelines without additional infrastructure.

This page was rewritten on 11 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.