How to Shrink a Docker Image (and Get an A-F Grade)

A Dockerfile that builds successfully is not the same thing as a good one. The gap between "it works" and "it's actually efficient" is almost always the same handful of mistakes — a bloated base image, layer ordering that busts the build cache on every change, cleanup commands that don't actually reduce image size, and secrets accidentally baked into image metadata. None of these break the build, which is exactly why they survive code review unnoticed.

Have a Dockerfile to check?

Open Docker Image Optimizer →

1. The base image is doing more than it needs to

FROM node pulls the full Debian-based image with a complete build toolchain — typically hundreds of megabytes more than necessary for a container that just needs to run compiled JavaScript. Switching to node:slim or node:alpine (or the equivalent for Python, Ruby, Go, and other ecosystems) often cuts image size dramatically with no functional difference for a typical runtime workload. Also watch for :latest or an untagged FROM line — without a pinned tag, the exact same Dockerfile can produce a different image tomorrow than it did today, which makes builds non-reproducible.

2. Layer ordering that busts the build cache

Docker caches each instruction as a layer and only reuses that cache if nothing above it changed. A Dockerfile that runs COPY . . before installing dependencies invalidates that cache on every single source-file edit — because the layer above the install step just changed, the install step re-runs from scratch, every time, even though the dependency manifest didn't actually change. The fix is layer ordering: copy only the dependency manifest first, install, then copy the rest of the source.

# Slow: any source change reinstalls all dependencies
COPY . .
RUN npm ci

# Fast: dependency layer only rebuilds when package*.json changes
COPY package*.json ./
RUN npm ci
COPY . .

3. Cleanup that doesn't actually shrink the image

Image layers are additive and immutable — a file created in one layer physically exists in that layer forever, even if a later layer deletes it. This means a separate RUN rm -rf /var/lib/apt/lists/* instruction after apt-get install does nothing for image size: both layers still ship. Cleanup only works when it happens in the same RUN instruction that created the files:

# Does NOT shrink the image — both layers persist
RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# Actually shrinks the image — one layer, cleaned before it closes
RUN apt-get update && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

4. Missing a multi-stage build

Any image that needs a compiler, a bundler, or dev dependencies to build the application but not to run it is a strong candidate for a multi-stage build: build in a first stage with the full toolchain, then copy only the compiled output into a slim runtime stage with COPY --from=build. The shipped image never sees the compiler, the dev dependencies, or the intermediate build artifacts — often the single biggest size reduction available.

5. Secrets baked into ENV or ARG values

An ENV API_KEY=abc123 or ARG DB_PASSWORD=... line doesn't just set a runtime value — it's baked permanently into the image's metadata, retrievable by anyone who can run docker history against it, even from an earlier stage of a multi-stage build that never made it into the final image. Secret-shaped values belong in a secrets manager, runtime environment injection, or BuildKit's dedicated --secret mount — never a hardcoded ENV/ARG line.

Other things worth checking

Getting an instant grade instead of checking by hand

Reviewing a Dockerfile against all of the above by eye is realistic for a short file and tedious for anything longer, especially across a multi-stage build where the same rule applies differently depending on which stage it's in. The Docker Image Optimizer runs 16 stage-aware checks covering everything above — base image bloat, layer-caching order, apt/npm/pip cleanup, secret-shaped ENV/ARG values, missing USER, ADD vs COPY, and more — and returns an A-F grade with line-anchored fixes you can copy or download. It's explicitly a static advisor, not a builder: nothing is pulled, built, or run, and the Dockerfile you paste is processed entirely in your browser.

Frequently Asked Questions

Why is my Docker image so much bigger than expected?

The most common causes are a full base image instead of a slim/alpine variant (node vs node:slim can differ by hundreds of MB), build tools and dev dependencies left in the final image instead of using a multi-stage build, and package manager cache files (apt lists, pip cache) not being cleaned up in the same layer they were created in. Checking these three in order usually accounts for most of the unnecessary size.

Why does deleting a file in a later RUN not shrink the image?

Docker images are built as a stack of layers, and each layer is immutable once created — a file added in one layer still physically exists in that layer even if a later layer deletes it, it's just hidden from the final filesystem view. The image on disk still contains both layers, so it doesn't shrink. Cleanup only reduces image size when it happens inside the same RUN instruction that created the files, e.g. apt-get install -y pkg && rm -rf /var/lib/apt/lists/* as one command.

Why does my Docker build reinstall dependencies on every single change?

Docker caches each instruction as a layer and reuses the cache only if nothing above it changed. If a Dockerfile runs COPY . . before npm ci or pip install, then editing any source file invalidates the COPY layer — and every layer after it, including the dependency install. The fix is to copy only the dependency manifest first (package.json, requirements.txt, go.mod), install dependencies, and copy the rest of the source afterward, so source edits don't bust the install layer.

When should a Dockerfile use a multi-stage build?

Whenever the image needs tools to build the application that it doesn't need to run it — compiling TypeScript, bundling a frontend, running go build, cargo build, or mvn package. Build in an initial stage with the full toolchain, then copy only the compiled output into a slim runtime stage with COPY --from=build. The final image ships without compilers, dev dependencies, or intermediate build artifacts, often cutting hundreds of megabytes.

Can secrets leak through Docker ENV or ARG values?

Yes. Values set with ENV or ARG in a Dockerfile are baked into the image's metadata and remain visible to anyone who can run docker history on it, even in later stages of a multi-stage build. Secret-shaped values (API keys, passwords, tokens) should never be hardcoded there — pass them at runtime via a secrets manager, environment injection, or Docker BuildKit's dedicated --secret mount instead.

Paste your Dockerfile and get an A-F grade with line-by-line fixes.

Open Docker Image Optimizer →
About the author

Pasindu Ishan is a software developer based in Sri Lanka. He builds developer tools at JSON Dev Tools.