← Back home

Why your Docker image is 1.2 GB (and how to fix it)

A teammate pinged me last month: their service image had quietly grown to 1.2 GB. The application was a single 14 MB binary. Somewhere along the way, the packaging had gone very wrong. It usually does, and it is almost never one dramatic mistake. It is a dozen small ones, each frozen into a layer.

Layers are append-only, and that is the whole problem

A Docker image is a stack of read-only layers, one per build instruction that changes the filesystem. The critical thing to internalize: deleting a file in a later layer does not reclaim its space. The bytes still exist in the earlier layer; the new layer just records a "whiteout" that hides them.

RUN wget https://example.com/toolkit.tar.gz   # +180 MB layer
RUN tar xzf toolkit.tar.gz                     # +180 MB layer
RUN rm toolkit.tar.gz                          # 0 bytes saved!

That archive lives in your image forever, even though ls inside the container will swear it is gone. This single misconception is responsible for most surprise bloat I have ever debugged.

The apt-get trap

The most common offender on Debian and Ubuntu bases is the package cache. Every apt-get install downloads .deb files and metadata into /var/lib/apt/lists and /var/cache/apt. If you clean them in a separate RUN, you have already paid for them in the install layer.

Wrong:

RUN apt-get update
RUN apt-get install -y curl ca-certificates
RUN apt-get clean   # too late, three layers too late

Right — one layer, cache cleaned before the layer is sealed:

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

The --no-install-recommends flag alone can shave hundreds of megabytes. By default apt pulls in "recommended" packages that you almost never need in a container — documentation, optional plugins, entire scripting runtimes dragged in as soft dependencies.

Multi-stage builds: the real fix

The cleanest way to ship a small image is to never let the build tooling into the final image at all. Compile in a fat builder stage, then copy only the artifact into a minimal runtime stage:

# ---- build stage ----
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/app

# ---- runtime stage ----
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]

The Go toolchain, module cache, and source tree all stay in the build stage and are discarded. The final image contains the binary and a handful of CA certificates — often under 20 MB. The same pattern works for Rust (cargo build --release then copy the binary), for Node (build assets, copy dist), and for almost anything that separates "build" from "run."

Order your layers for cache hits

Beyond size, layer order decides rebuild speed. Docker caches a layer until something it depends on changes — then that layer and everything after it is rebuilt. So copy the things that rarely change first:

# Good: dependency manifests first, source last
COPY package.json package-lock.json ./
RUN npm ci
COPY . .              # source changes do not bust the npm layer
RUN npm run build

If you COPY . . before installing dependencies, every one-character source edit invalidates the dependency install and you reinstall the world on each build. Same outcome, wildly different developer experience.

Find out where the bytes went

When an image is already fat, do not guess. Inspect the layer history:

# Per-layer size and the instruction that created it
docker history --no-trunc your-image:tag

# Total size and layer count
docker image inspect your-image:tag \
  --format '{{.Size}} bytes, {{len .RootFS.Layers}} layers'

docker history shows exactly which instruction added the 180 MB you cannot explain. Nine times out of ten it is an unclean package install, a copied build cache, or a downloaded archive that was "deleted" in a later layer.

The checklist I run

  • One RUN for install-and-clean, never split across layers.
  • --no-install-recommends on every apt install.
  • Multi-stage build so toolchains never reach the final image.
  • Copy dependency manifests before source for cache friendliness.
  • Pick the smallest sane base: distroless or alpine over a full distro.
  • When in doubt, docker history before you optimize.

My teammate's 1.2 GB image went to 31 MB. The fix was a multi-stage build and a single combined apt-get line. No clever tricks — just not freezing garbage into layers.


Filed under Containers. Questions or corrections welcome — see the about page.