Reducing Image Size

Reducing Docker image size means shipping only the files your application needs at runtime. Smaller images pull faster, use less disk, scan faster for vulnerabilities, and leave less unnecessary software inside production containers. The main tools are good base-image choices, careful layer design, .dockerignore, cleanup in the right layer, and multi-stage builds.

Overview: How it works

A Docker image is a stack of read-only layers plus configuration metadata. Each RUN, COPY, and ADD instruction can add filesystem content to the image. When a container starts, Docker mounts those image layers read-only and adds a thin writable layer for that one container. The container may look like one normal filesystem, but Docker is really presenting a merged view using a union filesystem such as overlay2 on Linux.

Image size is not just the visible size of files in the final container. Layers matter. If one layer writes a 200 MB package cache and a later layer deletes it, the final filesystem may not show the cache, but the earlier layer can still contain those bytes. That is why cleanup must happen in the same RUN instruction that creates temporary files, or the temporary files must stay in a build stage that is not part of the final image.

Base images matter too. A full operating-system image includes package managers, shells, libraries, certificates, and many tools. Sometimes that is useful for debugging or compatibility. In production, it is often waste. Alpine and slim Debian-based images can be much smaller, but they are not interchangeable: Alpine uses musl libc, while Debian and Ubuntu images use glibc. Some native packages behave differently, so choose the smallest base that still supports your application reliably.

The strongest size-reduction pattern is a multi-stage build. In one stage, you use compilers, package managers, source files, test tools, and build caches. In the final stage, you start from a smaller runtime image and copy only the compiled artifact or production files with COPY --from=builder. The final image does not inherit the builder stage’s layers unless you explicitly copy files from it.

Build caching and image size are related but not identical. A cache-friendly Dockerfile copies dependency manifests first so dependency installation is reused between builds. A small Dockerfile also avoids copying unnecessary files into any layer. Use .dockerignore to keep .git, local dependencies, coverage output, logs, and secret files out of the build context before Docker even considers COPY.

Syntax

docker build -t IMAGE:TAG PATH
docker image ls IMAGE
docker history IMAGE:TAG

FROM build-base:version AS builder
WORKDIR /src
RUN install-build-dependencies
COPY source-files ./
RUN build-command

FROM runtime-base:version
WORKDIR /app
COPY --from=builder /built/artifact ./artifact
CMD ["./artifact"]
Part Purpose
docker build -t IMAGE:TAG PATH Builds an image from a Dockerfile and build context.
docker image ls IMAGE Shows local image references and their virtual sizes.
docker history IMAGE:TAG Shows which instructions contributed layers and approximate sizes.
AS builder Names a build stage so later stages can copy from it.
COPY --from=builder Copies selected files from a previous stage into the current stage.
CMD Sets the default runtime command. It does not add application files by itself.

Use pinned base image tags such as node:20-alpine, golang:1.22-alpine, or nginx:1.27-alpine. Avoid latest for production examples because it is a moving target and makes image size and behavior less reproducible.

Examples

Example 1: Keep the build context small

node_modules
.git
coverage
.env
*.log
Dockerfile
README.md

Output:

#1 [internal] load build context
#1 transferring context: 18.74kB
#1 DONE 0.1s

This .dockerignore prevents common local files from being sent to the builder. The build context is the set of files Docker can copy into the image. If node_modules, Git history, logs, and environment files are not in the context, a broad COPY . . cannot accidentally add them to a layer. This reduces image bloat and also lowers the chance that secrets become part of an image.

Example 2: Build a smaller Node.js production image

FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY server.js ./
USER node
EXPOSE 3000
CMD ["node", "server.js"]
docker build -t node-api-small:1.0 .
docker image ls node-api-small

Output:

[+] Building 3.8s (12/12) FINISHED
REPOSITORY       TAG       IMAGE ID       CREATED          SIZE
node-api-small   1.0       7b31c6d4e5f2   12 seconds ago   142MB

The dependency stage installs only production dependencies with npm ci --omit=dev. The final stage copies node_modules, package.json, and the application entry file, not the entire working directory. It also runs as the existing node user. EXPOSE 3000 documents the container port only; users still need docker run -p 8080:3000 node-api-small:1.0 or Compose ports: to publish it.

Example 3: Compile in one stage, ship only the binary

FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /out/api ./cmd/api

FROM alpine:3.20
RUN addgroup -S app && adduser -S -G app app
WORKDIR /app
COPY --from=builder /out/api ./api
USER app
EXPOSE 8080
CMD ["./api"]
docker build -t go-api-small:1.0 .
docker history go-api-small:1.0

Output:

[+] Building 8.1s (14/14) FINISHED
IMAGE          CREATED          CREATED BY                                      SIZE
<missing>      8 seconds ago    CMD ["./api"]                                  0B
<missing>      8 seconds ago    EXPOSE map[8080/tcp:{}]                        0B
<missing>      8 seconds ago    USER app                                       0B
<missing>      8 seconds ago    COPY /out/api ./api                            12MB
<missing>      2 weeks ago      alpine:3.20 base layer                         7.8MB

The Go compiler and module cache exist only in the builder stage. The final stage starts from alpine:3.20 and copies one binary. This is usually much smaller than shipping golang:1.22-alpine as the runtime image, and it reduces the amount of software that scanners and operators must manage.

Example 4: Clean package-manager caches in the same layer

FROM alpine:3.20
RUN apk add --no-cache curl ca-certificates
CMD ["curl", "--version"]
docker build -t curl-tool:1.0 .
docker run --rm curl-tool:1.0

Output:

curl 8.14.1 (x86_64-alpine-linux-musl) libcurl/8.14.1 OpenSSL/3.5.1 zlib/1.3.1

Alpine’s apk add --no-cache installs packages without storing the package index in the image layer. On Debian-based images, the equivalent pattern is to run apt-get update, install packages, and remove /var/lib/apt/lists/* in the same RUN instruction. Splitting install and cleanup across layers can leave cache bytes behind.

How it works step by step

  1. The Docker client sends the build context to the builder. Files excluded by .dockerignore are never sent, so they cannot be copied into the image accidentally.
  2. Docker resolves the first FROM image and downloads missing base layers from the registry. Shared layers are stored once locally by digest.
  3. For each instruction, BuildKit checks the cache. If the instruction and its inputs match a previous build, Docker reuses the cached result.
  4. When a RUN instruction installs tools or creates temporary files, those filesystem changes become part of that layer unless they are cleaned up before the instruction finishes.
  5. In a multi-stage build, each FROM starts a separate layer chain. Later stages do not automatically contain files from earlier stages.
  6. COPY --from=builder copies only selected paths from the builder stage into the final stage. Everything else in the builder stays out of the shipped image.
  7. When you push the image, Docker uploads the final image’s missing layers and a manifest. Layers from discarded stages are not part of the final image reference.
  8. When a container runs, Docker mounts the final image layers read-only, adds a writable container layer, applies settings such as USER and CMD, and starts the process.

Common Mistakes

Using a builder image as the runtime image

FROM golang:1.22-alpine
WORKDIR /src
COPY . .
RUN go build -o api ./cmd/api
CMD ["./api"]

This works, but the final image includes the Go toolchain and build environment. The fix is to compile in a named stage and copy only the binary into a smaller runtime stage:

FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY . .
RUN go build -o /out/api ./cmd/api

FROM alpine:3.20
WORKDIR /app
COPY --from=builder /out/api ./api
CMD ["./api"]

Deleting large files in a later layer

FROM alpine:3.20
RUN dd if=/dev/zero of=/tmp/big-file bs=1M count=100
RUN rm /tmp/big-file
CMD ["sh"]

The file disappears from the final container view, but the earlier layer can still contain the 100 MB file. Create and remove temporary files in the same RUN instruction, or keep them in a builder stage that is not copied into the final image.

Copying the whole project too early

FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
CMD ["node", "server.js"]

A broad copy can add tests, docs, local build output, and other files unless .dockerignore is excellent. It also invalidates dependency installation whenever any source file changes. Copy package manifests first, install dependencies, then copy only the runtime files you actually need.

Expecting EXPOSE to publish a port

FROM node:20-alpine
WORKDIR /app
COPY server.js ./
EXPOSE 3000
CMD ["node", "server.js"]

EXPOSE is useful metadata, but it does not open a host port. Publish the port at runtime with docker run -p 8080:3000 image:tag or with Compose ports:. This does not reduce size, but it is a common misunderstanding when testing minimal images.

Baking secrets into an image layer

FROM alpine:3.20
RUN echo "<YOUR_API_KEY>" > /tmp/api-key.txt
RUN rm /tmp/api-key.txt
CMD ["sh"]

Deleting the file later does not make this safe. Secrets can remain in earlier layers or build history. Use runtime secret injection, mounted files, Docker secrets, or your orchestrator’s secret store instead of writing credentials in a Dockerfile.

Best Practices

  • Start from the smallest pinned base image that your application can run on reliably.
  • Use multi-stage builds for compiled applications and frontend build pipelines.
  • Copy only runtime artifacts into the final stage.
  • Use .dockerignore to exclude local dependencies, Git metadata, test output, logs, and secret files.
  • Install only production dependencies in runtime images, such as npm ci --omit=dev.
  • Clean package-manager caches in the same RUN instruction that creates them.
  • Avoid broad COPY . . in final stages when a narrower copy is enough.
  • Inspect size with docker image ls and layer contribution with docker history.
  • Do not use latest for production bases; pin tags and consider digest pinning in stricter environments.
  • Keep secrets out of Dockerfiles, image layers, and build logs.

Practice Exercises

  1. Write a .dockerignore for a Node project that excludes node_modules, .git, coverage, .env, and log files. Expected end state: the build context transfer is noticeably smaller.
  2. Convert a one-stage Go Dockerfile into a multi-stage build. Hint: compile in golang:1.22-alpine, then copy only the binary into alpine:3.20.
  3. Take a Dockerfile that installs packages and deletes caches in separate RUN instructions. Rewrite it so install and cleanup happen in one instruction, then compare docker history.

Summary

  • Small images pull faster, store less data, scan faster, and contain less unnecessary software.
  • Image layers are immutable, so deleting files later does not always remove their bytes from earlier layers.
  • .dockerignore reduces build context size and prevents accidental copies.
  • Multi-stage builds are the standard way to keep compilers and build caches out of production images.
  • Use pinned base images and choose slim or Alpine variants only when they are compatible with your app.
  • Clean package-manager caches in the same layer where they are created.
  • Use docker image ls and docker history to measure results instead of guessing.