All articles
169 articles · updated weekly See our Tools
All articles
Tutorials

How to reduce Docker image size

Learn how to reduce Docker image size, compare the base image alternatives, and avoid the most common mistakes before shipping to production.

How to reduce Docker image size
COVER · Tutorials

You open the CI pipeline and see the image build sitting at 1.4 GB. The deploy drags because every node in the cluster has to pull that from the registry, the vulnerability scanner flags 200 CVEs coming from packages you never used, and the registry bill creeps up every month. In most cases the culprit isn't your application — it's build tool layers, package manager cache, and a fat base image nobody has reviewed since the first commit. A big image isn't just cosmetic: it's pull time, attack surface, and cost.

This article is about trimming that weight systematically without breaking the application. If your image also mounts volumes at runtime, it's worth reading about Docker volumes afterward, because what goes into the image and what stays on the volume are separate decisions people often blur together.

Why a fat image hurts (and it's not just vanity)

Three concrete costs:

  • Pull time and cold start. Every new replica downloads the whole image. Under autoscaling, that becomes provisioning latency at the worst possible moment — when traffic is ramping up.
  • Attack surface. Every binary on the filesystem is a potential CVE. A leftover apt install build-essential in the final stage drags in a compiler, headers, and dozens of libs that have no reason to exist in production.
  • Registry and bandwidth cost. Large layers multiply across tags, environments, and nodes.

The good news: you can attack all of this with techniques that require no rewrite of the application.

Multi-stage build: separate building from running

The biggest lever is the multi-stage build. The idea is simple: one stage compiles/installs dependencies with all the heavy tooling, and a final stage copies only the finished artifact. The compiler, the npm cache, the dev headers — none of that survives into the final image.

Before, everything in one stage:

FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]

This carries the npm cache, devDependencies, and the entire toolchain of node:20 (~1.1 GB). Now, with multi-stage:

# build stage
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --production

# runtime stage
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

The final stage has no source code, no devDependencies, no npm cache. For compiled languages (Go, Rust) the win is even more brutal: the final stage can be a scratch or distroless with a single static binary.

Base image: alpine, slim, or distroless

The base choice is the second biggest lever, and it carries a caveat that trips people up daily.

  • -slim (lean Debian): keeps glibc, drops docs and optional packages. Maximum compatibility, medium weight. It's the safe default.
  • alpine: tiny, but uses musl libc instead of glibc. Packages with native binaries compiled against glibc can break in subtle ways — segfaults, DNS resolving wrong, weird performance in libs that depend on specific allocator behavior. It's not "alpine is bad," it's "alpine demands that you actually test."
  • distroless (Google): no shell, no package manager, just the runtime and your app. Minimal attack surface, glibc-based, so no musl problem. The cost is debugging: there's no sh to exec into, so you need a separate :debug tag for investigation.

My practical recommendation: -slim to start, distroless once the image stabilizes and you want to cut attack surface. alpine only with a test suite that exercises the real runtime.

Layer caching and .dockerignore

Docker caches per layer. If you copy the whole source before installing dependencies, any one-line change invalidates the npm install cache and it reruns on every build. That's why COPY package*.json comes before COPY . . — manifests change less often than code.

The .dockerignore is the forgotten sibling. Without it, COPY . . throws your local node_modules, .git, logs, and IDE files into the build context and potentially into the image:

.git
node_modules
npm-debug.log
.env
*.md
dist
coverage

This shrinks the build context (faster upload to the daemon) and avoids leaking .env secrets into the image.

Combine RUN and clean up in the same layer

Each RUN is a layer. And layers are append-only: deleting a file in a later RUN doesn't reclaim the space — the file stays in the earlier layer, just hidden. So installing and cleaning must happen in the same RUN:

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

If rm -rf /var/lib/apt/lists/* were in a separate RUN, the apt cache would still weigh down the earlier layer. The --no-install-recommends cuts "suggested" packages that are almost never needed.

Don't ship the build toolchain to production

This is the mistake I see most. The final stage shouldn't have a compiler, dev headers (-dev packages), or test tooling. If you need to compile a native extension, do it in the build stage and copy only the result. A classic symptom: a final python:3.12 with gcc and build-essential installed because some pip package needed to compile on the fly. The fix: build wheels in the build stage, install prebuilt wheels in the runtime stage.

Frequently asked questions

Does alpine always make the image smaller?

Smaller on disk, yes, almost always. But "smaller" isn't the only criterion. musl libc can cause subtle incompatibilities with native binaries compiled against glibc, and in some cases build time goes up because you recompile things that came prebuilt on Debian. Use alpine when the size win justifies the cost of testing the real runtime.

Does multi-stage build make the build slower?

Not significantly, and often it's faster thanks to better caching. Each stage is cached independently, so code changes don't invalidate the dependency stage. The pull and deploy savings outweigh any marginal overhead.

Is distroless worth it if you can't shell into the container?

For production, yes. The absence of a shell and package manager is exactly the point: it cuts attack surface and removes tools an attacker would use. For debugging, use the :debug variant, which bundles a busybox shell, only in investigation environments.

How do I measure what's taking up space in the image?

docker history <image> shows the size of each layer and the command that created it. Tools like dive let you browse layer by layer and see exactly which files landed in each one. Start with the largest layer.

What to take into the next build

Reducing Docker image size is discipline, not magic. The order of impact usually goes: multi-stage build first (separate build from runtime), lean base image next (-slim or distroless), then layer hygiene — .dockerignore, combined RUN steps, cleanup in the same layer, and no build toolchain in the final stage. Measure with docker history and dive before and after each change; optimizing blind is like tuning a query without EXPLAIN. The goal isn't the tiniest possible image, it's the smallest image you can still operate and debug safely.

RD
Author
Rafael Duarte
Backend developer with a background in fintech and B2B SaaS — worked on teams that scaled APIs from zero to millions of requests. Carries enough production scars to hold strong opinions on tools, patterns and architecture decisions. Not an academic: he read the UUID RFC when he had to choose between v4 and v7 for a write-heavy table.
View profile