Back to blog

Why Your Docker Image Is 3GB and Nobody Noticed Until the Deploy Timed Out

Aug 19, 2026
9 min read
JJS
Written by Jatin Jain Saraf · Senior Software Engineer

Why Your Docker Image Is 3GB and Nobody Noticed Until the Deploy Timed Out

A service that used to deploy in ninety seconds now takes six minutes. Nobody changed the code that day. The base image hasn't changed, docker push is uploading to the same registry it always has, and the CI runner is the same size it always has been. What changed is that the image itself quietly grew, build after build, revision after revision, until one day the push step timed out and someone finally looked. Nothing broke on purpose. The image just never stopped getting heavier, and nobody was watching the number.


An Image Is a Stack of Layers, Not a Snapshot

A Docker image isn't one file, it's a stack of read-only layers, one per instruction in the Dockerfile that changes the filesystem: a RUN, a COPY, an ADD. Each layer is a diff against the one below it, and at runtime the container's filesystem is presented as the combination of all those layers through a union filesystem, with a thin writable layer on top for the container itself. The layers stay separate on disk; nothing flattens them into one file. Crucially, a layer never shrinks once written. If a RUN apt-get install layer downloads 400MB of packages and a later RUN apt-get clean layer deletes the package cache, the image doesn't get 400MB smaller. The delete happens in a new layer on top; the old layer with the 400MB still sits underneath it, and that layer remains part of the image that nodes need to have available. The deleted file disappears from the filesystem you actually see when the container runs, but the bytes are still physically present in the image and still contribute to its size. If a registry or node doesn't already have that layer, those bytes have to be transferred during a push or pull.

Think of it like a suitcase you never fully unpack between trips. You don't take everything out and repack from scratch, you just add what you need for this trip on top of what's already in there. Take something out and it's not gone, it's still in the suitcase, just buried under a note that says "ignore this." The suitcase only gets heavier. That's a Docker image with unpruned layers: every stale cache, every intermediate build tool, every "temporary" file that got rm'd in a later step is still physically present, just marked as superseded.

Why this shows up in production: a base image with a full OS toolchain (Ubuntu with build-essential, a full Node.js image instead of a smaller variant) starts you 500MB to 1GB heavier before your application code exists at all. Every dependency installed and not cleaned up in the same layer, every COPY . . that grabs node_modules, .git, and test fixtures because there's no .dockerignore, adds weight that never comes back off. None of it fails a build. It just makes every image bigger than the last one, silently, until push and pull times are the bottleneck. A rough breakdown of how an image gets to 3GB:

text

No single layer looks unreasonable on its own. It's the accumulation across all of them, plus the ones that should have been discarded but structurally can't be, that adds up.

Why Bad Layer Ordering Makes Every Build Slower

Layer caching is supposed to make builds fast: Docker walks the Dockerfile instruction by instruction and reuses the cached result for each one, until it hits the first instruction whose cache is invalid, either because the instruction itself changed or because a file it depends on changed. From that instruction onward, every remaining layer has to be rebuilt, even if most of them don't actually depend on what changed. That's the entire point of the layer model, and it only works if the Dockerfile is ordered so that the things which change least often come first, and the things that change on every commit come last.

The single most common mistake is COPY . . before the dependency-install step:

dockerfile

Every commit touches some file in the repo, so that COPY layer's cache is invalidated on every build, and npm install right after it has to rerun from a cold cache every time, even when package.json didn't change. Reordering it fixes the problem directly:

dockerfile

Now changing src/foo.js invalidates only the final COPY, and the dependency-install layer above it stays cached. A build that might otherwise take fifteen seconds because nothing dependency-related moved instead can take three minutes on the bad version, on every revision, forever, because the Dockerfile put the wrong instruction first.

Why this shows up in production: this is the mechanism behind "deploys used to be fast and now they're not," with no code change to point at. It's not the application getting slower, it's the build losing its cache on every run because of layer ordering, on top of an image that's also grown heavier over time, so both the build step and the push/pull step degrade independently and get blamed on each other.

The Registry Doesn't Forget Either

The same "nothing shrinks unless someone tells it to" problem exists one level up, in the artifact registry itself. Every build that pushes a new tag, latest, a commit SHA, a version number, adds a new manifest and a new set of layer blobs to the registry. Layers that are byte-identical get deduplicated by content hash, which is why the registry doesn't grow linearly with every push, but every layer that's actually different (a new dependency version, a new base image patch, a rebuilt application layer) is new data that has to be stored, and old images don't get deleted just because a newer one exists.

A registry doesn't necessarily know which images are no longer useful. Unless you give it retention rules or clean them up yourself, old manifests and their unique layer blobs just accumulate: every feature-branch build, every hotfix, every image built by a CI run that never got cleaned up, all still sitting there, still counted against storage. A registry with a two-year-old image nobody has pulled in eighteen months isn't just wasted storage, it's an image nobody is auditing for CVEs anymore, sitting right next to the one that's actually in production, indistinguishable to anyone browsing tags without a naming convention.

Why this shows up in production: registry storage bills climbing with no obvious cause, and CI jobs or registry maintenance operations getting slower or more expensive as a repository accumulates large numbers of manifests and unique layer blobs. A single docker pull for a specific tag isn't slowed down by unrelated stale tags sitting elsewhere in the repository, it only fetches the layers that tag's manifest actually references, but repository-wide operations, listing tags, running garbage collection, scanning for vulnerabilities across everything stored, all get heavier as the pile grows. The fix isn't a bigger registry plan, it's a retention policy: expire untagged manifests and feature-branch tags after N days, keep only the last M builds per branch, and let content-addressable deduplication do the rest.

Measure Before You Guess

Before reordering a Dockerfile or swapping a base image, find out what's actually taking up space. Two commands answer that directly:

bash

Lists every layer in the image with its size, in order, so you can see exactly which instruction added the most weight.

bash

Gives the full layer manifest and metadata, useful for scripting size checks in CI. For a closer look at what's actually sitting inside the largest layers, a tool like dive walks the filesystem contents layer by layer, which is usually faster than guessing from the Dockerfile alone. Run docker image history first: it takes thirty seconds and tells you whether the problem is the base image, an unpruned dependency cache, or the build context, before you change anything.

What Actually Fixes It

  • Order the Dockerfile by change frequency. Dependency manifests (package.json, requirements.txt) get copied and installed first, source code gets copied last. That one reordering is usually the single biggest build-time win available.
  • Use multi-stage builds. Build in one stage with the full toolchain, compilers, dev dependencies, and copy only the compiled output into a clean final stage. The build tools never make it into the image that ships. A simplified Node example, for a service that produces a self-contained dist directory:
dockerfile

The final image only contains what COPY --from=build explicitly pulls forward: the compiled output and production dependencies. The compiler, dev dependencies, and source files from the build stage never exist in the shipped image at all.

  • Pick a smaller runtime image where it makes sense. -slim, distroless, or Alpine variants are often the difference between a 900MB image and a 90MB one before any application code is added, but they're not a free win in every case: Alpine's musl libc can surface compatibility issues with some native dependencies, so weigh the size/security gain against your actual runtime's tolerance for it rather than defaulting to it blindly.
  • Write a real .dockerignore. node_modules, .git, test fixtures, and local env files should never be in the build context in the first place. This keeps the image smaller directly, and it also keeps irrelevant files from ever reaching a COPY layer and invalidating its cache for no reason.
  • Set a registry retention policy. Expire untagged and stale feature-branch images automatically instead of relying on someone remembering to clean up. Most registries (ECR, Artifact Registry, GitHub Container Registry, Harbor) support this natively.

None of these are exotic. They're three separate mechanisms with the same operational lesson: unused data doesn't disappear unless you design for it or clean it up on purpose. A service that deploys in six minutes instead of ninety seconds usually isn't a mystery. It's a suitcase that's been repacked on top of itself for a year, and nobody's taken anything out.

Discussion

0

Join the discussion

Loading comments...