Guides/DockerDocker/Optimizing Docker Image Size and Build Speed

Optimizing Docker Image Size and Build Speed

Shrink Docker images and speed up builds: measure with dive and docker history, cache layers, use BuildKit cache mounts, multi-stage builds, and registry cache in CI.


A 1.2GB image that takes four minutes to build is not just an aesthetic problem. It is slower to pull on every deploy and every autoscale event, it costs more to store and transfer, it wastes CI minutes rebuilding things that did not change, and every extra package in it is one more thing a vulnerability scanner can flag and an attacker can use. Small, fast-building images are faster to ship, cheaper to run, and safer to operate. This guide is about getting there methodically: measure first so you know where the weight and the wasted time actually are, then apply caching, BuildKit, multi-stage builds, and smaller bases with evidence rather than superstition. It assumes you can already write a working Dockerfile - the Writing Dockerfiles guide covers the instructions and the basic patterns; this one is about squeezing size and speed out of an image you already build.

Why it matters, concretely

It is easy to treat image size as vanity, so be clear about what it actually costs.

  • Deploy and scale speed. Every rollout, every new node, every autoscaled replica pulls the image. A 40MB image pulls in a second; a 1.2GB image can take a minute over a slow registry link, multiplied by every node. When you are scaling up under load, that pull time is added latency at the worst possible moment.
  • Cost. Registries charge for storage and egress. CI charges for minutes. A build that reinstalls every dependency on every commit burns paid runner time on work it already did last build. Multiply by a team pushing dozens of times a day.
  • Attack surface. Every binary, shell, and package in the image is code an attacker can use if they get in, and a line item a scanner reports. Most CVEs flagged on a typical image live in build tools and OS packages you never actually run. Removing them removes the finding.
  • Feedback loop. A build that takes three seconds on a code change keeps you in flow. One that takes three minutes pushes you to batch changes, context-switch, and lose the plot. Build speed is developer experience.

The rest of this guide is the toolkit for improving all four, and none of it should be applied blind - it starts with measuring.

Measure first

You cannot optimize what you have not measured, and image bloat is rarely where you guess. Before changing a Dockerfile, find out how big the image is and where the bytes live.

The first command is docker images, which shows the size of the final image:

docker images myapp
# REPOSITORY   TAG      IMAGE ID       CREATED         SIZE
# myapp        latest   9f3c1a2b4d5e   2 minutes ago   1.18GB

That is the number you are trying to shrink, but it does not tell you why it is that big. For that, use docker history, which breaks the image down by layer and shows the size each instruction added:

docker history myapp
# IMAGE          CREATED BY                                      SIZE
# 9f3c1a2b4d5e   CMD ["node" "server.js"]                        0B
# <missing>      COPY . . # buildkit                             320MB
# <missing>      RUN npm install                                 610MB
# <missing>      COPY package.json .                             2kB
# <missing>      /bin/sh -c #(nop) FROM node:20                  1.09GB

Read that bottom-up: the node:20 base is already over a gigabyte, the npm install adds 610MB, and COPY . . dumped another 320MB (probably a node_modules or .git that should have been ignored). Now you know the three levers - a smaller base, a leaner dependency install, and a .dockerignore - instead of guessing. docker history --no-trunc shows the full command if it is cut off.

For a deeper look, the dive tool (github.com/wagoodman/dive) is worth installing. It shows each layer's contents interactively and, crucially, computes an efficiency score and flags wasted space - files added in one layer and deleted in a later one (which, as the Dockerfiles guide explains, still ship in the earlier layer). Run it against your image:

dive myapp

It highlights exactly which files are eating space and where you shipped something you then deleted - the classic "I rm-ed the cache in a later RUN so it must be gone" mistake that docker history alone will not catch. Measure with these three, decide what to attack, then change one thing and measure again. Optimization without measurement is how you spend an afternoon shaving 3MB while a 600MB base sits untouched.

Layer caching: the free speed win

The single biggest lever on build speed is the layer cache, and it is free once you order your instructions correctly. Docker caches every layer and, on a rebuild, reuses cached layers top to bottom until it hits one whose input changed - from there down, everything rebuilds. So the rule is: put what rarely changes at the top and what changes every commit at the bottom.

The Dockerfiles guide covers the canonical form of this in depth, so here it is in one breath: copy your dependency manifests and install them before copying your source, so a code edit does not invalidate the (slow) dependency install.

FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./   # changes rarely
RUN npm ci                               # stays cached across code edits
COPY . .                                 # changes every commit - lands last
CMD ["node", "server.js"]

That ordering is what turns a three-minute rebuild into a three-second one on a normal code change. Everything below in this guide is about making the cache work even harder - across package-manager caches with BuildKit, and across CI runners with a registry cache. But get the ordering right first; it is the highest return for the least effort, and no amount of cache trickery saves a Dockerfile that copies source before installing dependencies.

BuildKit and cache mounts

Modern Docker uses BuildKit as its build engine (it is the default in current Docker versions; on older ones, enable it with DOCKER_BUILDKIT=1 docker build ...). BuildKit is faster on its own - it builds independent stages in parallel and skips stages nothing depends on - but its most useful feature for build speed is the cache mount.

Here is the problem it solves. Layer caching helps when a manifest did not change at all. But when you do change one dependency, the whole RUN npm ci (or pip install, or apt-get install) layer reruns, and it re-downloads every package from the network, even the ones that did not change, because the package manager's own download cache is not part of the layer and starts empty each time. Cache mounts fix this by mounting a persistent directory that survives across builds but is not committed into the image.

# syntax=docker/dockerfile:1
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci
COPY . .
CMD ["node", "server.js"]

The --mount=type=cache,target=/root/.npm line mounts npm's download cache for the duration of that RUN. When the manifest changes and npm ci reruns, npm finds its previously downloaded tarballs in the mounted cache and only fetches what is genuinely new - a re-run that took 90 seconds of downloading can drop to a few seconds. The cache lives on the build host, not in the image, so it adds zero bytes to what you ship. Note the # syntax=docker/dockerfile:1 line at the top - it opts into the current Dockerfile frontend, which cache mounts require.

The same pattern applies to every package manager; you just point the mount at the right cache directory:

# Python / pip
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

# Go modules and build cache
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go build -o /out/app ./cmd/server

# Debian apt (also disable the auto-clean that empties the cache)
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
    apt-get update && apt-get install -y --no-install-recommends curl

Cache mounts and correct layer ordering are complementary: ordering keeps the whole layer cached when nothing changed, and cache mounts make the rebuild cheap when something did change. Together they are the difference between a dependency change costing you a full re-download and costing you almost nothing.

Multi-stage builds: ship only the artifact

For image size, the highest-leverage technique is the multi-stage build: compile or bundle in a big image full of tools, then copy only the finished artifact into a tiny final image. Build tools, compilers, dev dependencies, and intermediate files never reach what you ship. The Dockerfiles guide walks through the mechanics; the point to reinforce here is what it does to the number on the scale.

Consider a Go service built naively in a single stage versus a multi-stage build:

# ---- build stage: has the whole Go toolchain ----
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go build -o /out/app ./cmd/server

# ---- final stage: just the binary on a near-empty base ----
FROM gcr.io/distroless/static:nonroot
COPY --from=build /out/app /app
USER nonroot
ENTRYPOINT ["/app"]

The golang:1.22 image is around 800MB. Ship that as your final image and it is 800MB plus your binary. With the multi-stage split, the final image is the distroless base (a couple of MB) plus a ~15MB static binary - roughly 800MB down to under 20MB, a 40x cut. The compiler, module cache, and source all stay behind in the discarded build stage. The same shape takes a Node frontend from a ~1GB node:20 image down to a small nginx image serving static files, because the final image needs no Node, no npm, and no node_modules - just the built assets. Build fat, ship thin, and copy only the artifact across the boundary.

Choosing a smaller base

Your FROM line for the final stage sets the floor on image size, so choose it deliberately. From largest to smallest:

  • Full (node:20, python:3.12, debian:12) - complete OS, package manager, shell, plenty of tooling. Often 400MB to 1GB+. The right choice for a build stage where you want everything; rarely what you ship.
  • Slim (node:20-slim, python:3.12-slim) - the same Debian base with docs, extra locales, and rarely-used packages removed. Typically a fraction of the full size while keeping glibc and a normal apt, so software "just works." A sensible default final base for most apps.
  • Alpine (node:20-alpine, alpine:3.20) - built on musl libc and BusyBox; the base image is around 5MB. Tiny, but watch the musl-vs-glibc gotcha: software with precompiled native binaries or C extensions (many Python wheels, some Node native modules) is built against glibc and can fail, misbehave, or force a slow recompile from source on musl. Excellent when your stack is musl-clean, a time sink when it is not. Test before you commit to it.
  • Distroless (gcr.io/distroless/static, gcr.io/distroless/base) - just your app and its runtime dependencies: no shell, no package manager, no coreutils. The smallest attack surface short of scratch, and a great final stage for a compiled binary. The tradeoff is debuggability - with no shell you cannot docker exec ... sh into it, so debugging moves to logs and ephemeral debug containers. There is a :debug variant with a BusyBox shell for when you truly need to get inside.

The pragmatic play: full image for the build stage, and the smallest base you can operate for the final stage - slim for a normal shell and glibc, distroless for minimum surface, alpine only when proven musl-clean. Do not chase the last few megabytes into a base you cannot debug during an incident.

.dockerignore: shrink the build context

Before Docker builds anything, it packages up the build context - the directory you point docker build at - and hands it to the build engine. If that directory holds node_modules, a .git folder with the whole history, build output, and gigabytes of test fixtures, all of it gets bundled on every build even if no instruction copies it. That slows every build, and a careless COPY . . will pull the junk (and any secrets) straight into your image.

A .dockerignore (same syntax as .gitignore, sitting next to the Dockerfile) excludes paths from the context. It shrinks the context so builds start faster, stops COPY . . from shipping things you never meant to, and keeps the cache stable because ignored files cannot invalidate a COPY layer. A reasonable Node starting point:

.git
node_modules
dist
build
coverage
.env
.env.*
*.md
Dockerfile
.dockerignore

The size and speed win is real - excluding a multi-hundred-megabyte .git and a stale node_modules can cut the context (and thus the transfer at the start of every build) dramatically, and it also closes the .env-leaking-into-a-layer hole. Write it at the same time as the Dockerfile, not after you notice the image is 900MB.

Combine and clean RUN layers

Every RUN is a committed, immutable layer, so anything a RUN creates and a later RUN deletes still ships in the earlier layer. The classic offender is the OS package cache:

# BAD - the apt package lists live in a layer forever, tens of MB of dead weight
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*   # too late - the cache is already in earlier layers

The fix is to update, install, and clean in a single RUN so the cleanup lands in the same layer that created the cache:

# GOOD - nothing left behind; the whole thing is one lean layer
RUN apt-get update \
    && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

The same logic applies everywhere: pip install --no-cache-dir so pip does not leave its wheel cache in the layer, npm ci && npm cache clean --force (or a cache mount, which is cleaner), and removing any tarball you downloaded and extracted in the same RUN that used it. The --no-install-recommends flag matters too - it stops apt dragging in a pile of suggested extras you never asked for. Use dive to catch the wasted space when you are not sure where it is hiding. (Note: if you use a BuildKit cache mount for the package cache as shown earlier, you do not need to rm it - the cache lives on the mount, outside the image, so it never bloats a layer in the first place.)

Registry-backed build cache in CI

Everything so far assumes a build host that keeps its cache between builds - which is true on your laptop and false on most CI. A fresh CI runner starts with an empty local cache every time, so all your careful layer ordering buys nothing: every build is a cold build. The fix is to store the build cache in your registry and load it at the start of each CI build, so runners share a cache even though they share no disk.

BuildKit supports this directly through --cache-to and --cache-from, pointing at a registry location:

docker buildx build \
  --cache-from type=registry,ref=myregistry/myapp:buildcache \
  --cache-to   type=registry,ref=myregistry/myapp:buildcache,mode=max \
  -t myregistry/myapp:latest \
  --push .

--cache-from seeds the build with cached layers pulled from the registry; --cache-to ... mode=max pushes the cache (including intermediate multi-stage layers, which mode=max preserves and the default mode=min does not) back for the next build to use. Now a CI build where only the source changed reuses the dependency layers from the registry cache and skips the slow install entirely, just as your laptop would.

On GitHub Actions with docker/build-push-action, the same idea has a first-class form:

- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: myregistry/myapp:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

type=gha uses the runner's built-in Actions cache instead of your registry - convenient and free within Actions, though it has size limits, so for large images type=registry against your own registry is often more reliable. Either way, the goal is the same: give ephemeral CI runners a shared, persistent cache so a code-only change does not pay for a full dependency install every pipeline run. This is frequently the single biggest CI speed win available, because it recovers all the layer-cache benefit that a stateless runner otherwise throws away on every build.

The shape of it

Small, fast images are worth the effort because they deploy and scale faster, cost less to store and run, and expose less to attack. The method is always the same: measure first with docker images, docker history, and dive so you optimize the real weight instead of guessing. Then order your layers least- to most-frequently-changing so a code edit does not blow the dependency cache; add BuildKit cache mounts so even a genuine dependency change does not re-download the world; use multi-stage builds to compile fat and ship thin, cutting size by 10x or more; pick the smallest final base you can operate; keep the build context clean with .dockerignore; and combine and clean RUN layers so deleted files do not haunt earlier layers. Finally, in CI, back your cache with the registry so stateless runners stop rebuilding from cold. Do these and your images go from gigabytes and minutes to megabytes and seconds - measured, not hoped.