A 1.2 GB image for a service that's a few megabytes of compiled code is one of those things everyone accepts until someone actually looks at it. Slow pulls, slow deploys, a bigger attack surface, and a registry bill that creeps up quietly. The good news is that bloated images are almost always the result of a handful of specific, fixable habits — not some deep property of Docker.
I want to walk through how I actually slim an image down, in the order I'd do it, with a before-and-after you can map onto your own Dockerfile. Nothing here is exotic. It's mostly about not shipping things you don't need, and knowing how to see what you are shipping.
First, look before you cut
You can't slim what you can't see, so the two commands I reach for first are:
docker history myapp:1.4.2
docker image inspect myapp:1.4.2
docker history breaks the image down layer by layer with the instruction and size of each. This is where the offenders reveal themselves — a RUN that installed a compiler, an apt cache nobody cleaned up, a COPY that swept in a local node_modules or a .git directory. docker image inspect gives you the full metadata, including environment variables and the layer digests.
Measure before you tune. I've watched people spend an afternoon switching base images to save 30 MB while a stray 400 MB build-tools layer sat right there in docker history. Read the layer list first, then decide where the real weight is.
The big lever: multi-stage builds
For anything compiled or bundled, multi-stage builds are the single biggest win, so I'll start there. The idea: use one stage with the full toolchain to build your artifact, then copy only the finished artifact into a clean, minimal final stage. All the compilers, headers, and dev dependencies stay behind in the build stage and never reach the shipped image.
Here's a Go service done the naive way:
# Before — ships the entire Go toolchain
FROM golang:1.22
WORKDIR /src
COPY . .
RUN go build -o /bin/app ./cmd/app
CMD ["/bin/app"]
That image carries the whole golang toolchain — easily 700+ MB — to run a single static binary. Now the multi-stage version:
# After — build stage stays behind, final image ships only the binary
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 /bin/app ./cmd/app
FROM gcr.io/distroless/static-debian12
COPY --from=build /bin/app /bin/app
USER nonroot:nonroot
CMD ["/bin/app"]
The final image contains the binary and almost nothing else. The same pattern applies to Node (build/bundle in one stage, copy dist and production node_modules into a slim runtime), Java, Rust, Python with wheels — anything with a build step that produces an artifact you can hand off.
Pick a smaller base — and be honest about the tradeoffs
Your base image is often most of your size, so it's worth choosing deliberately. There's a rough ladder from biggest to smallest:
-
Full distro (
debian,ubuntu,node:20) — everything included, largest. -
-slimvariants (debian:12-slim,node:20-slim) — the same familiar userland with docs, man pages, and extras stripped. Usually my default. You keep glibc and a normal package manager, you just carry less. -
Alpine (
alpine,node:20-alpine) — tiny, but built on musl libc instead of glibc. - Distroless / scratch — no shell, no package manager, minimal or zero OS. Smallest and lowest attack surface, but hardest to debug interactively.
Let me be honest about Alpine, because "just use alpine" gets repeated as if it's free. It's genuinely small, but musl is not glibc, and that difference is real. Some Python wheels won't have musl builds and fall back to compiling from source; occasionally you hit subtle DNS-resolution or locale differences; and there have been well-documented cases of performance quirks under musl for certain workloads. None of that makes Alpine wrong — plenty of production runs happily on it — but "smaller image" isn't the whole equation. If you're on glibc everywhere else and don't want surprises, -slim is the boring, safe middle ground. The boring answer is usually the right one here.
And whatever you choose, pin it. Prefer a digest for reproducibility:
FROM debian:12-slim@sha256:2a1f...b8e0
A floating tag like latest means the image can change under you between builds, which is exactly the kind of non-determinism you don't want in a runtime base.
.dockerignore — stop shipping your repo
This one is almost free and constantly overlooked. Every COPY . . sends your entire build context to the daemon, and whatever isn't ignored can end up baked into a layer — .git, local node_modules, test fixtures, .env files, build output. A tight .dockerignore prevents all of that:
.git
node_modules
*.log
.env
.env.*
dist
coverage
Dockerfile
README.md
Beyond size, this also protects your build cache: if .git is in the context, your COPY . . layer busts every single commit even when no source file changed. Excluding .env* is a nice belt-and-suspenders against secrets, too — which brings me to the next point.
Clean package caches in the same RUN layer
Remember that every RUN creates a layer, and a layer is a permanent diff. If you install packages in one RUN and delete the cache in a later RUN, the cached files still live in the earlier layer forever. The final image only hides them; it doesn't shrink. Deleting a file in a later layer can actually make the image slightly larger, because now you're storing both the file and the record of its deletion.
So do the install and the cleanup in one instruction:
# Wrong — cache lives on in the earlier layer
RUN apt-get update && apt-get install -y curl ca-certificates
RUN rm -rf /var/lib/apt/lists/*
# Right — install and clean in the same layer
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 matters more than it looks — it skips the pile of "suggested" packages apt would otherwise drag in. Same principle for other tools: pip install --no-cache-dir, npm ci && npm cache clean --force, apk add --no-cache.
Don't bake secrets into layers
While we're on layers being permanent: never COPY a secret in, use it, and RM it in a later step. It's still sitting in the earlier layer, fully readable to anyone who pulls the image and runs docker history or unpacks the tar. Same goes for putting secrets in ENV — they're baked into the image metadata.
If you need a credential at build time (a private registry token, an SSH key for a private dependency), use BuildKit secret mounts so it's never written to a layer at all:
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci --omit=dev
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp:1.4.2 .
The secret is available only during that RUN and leaves no trace in the final image. For runtime secrets, inject them as environment variables or mounted files when the container starts — never at build.
While you're tightening things up, it's worth running the finished Dockerfile through the free Dockerfile validator — it catches a lot of the structural mistakes above (missing --no-install-recommends, secrets in ENV, unpinned bases) before they ever reach a build.
The before/after, end to end
Putting the habits together on a Node service:
# Before — ~1.1 GB
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
# After — often well under 200 MB
# syntax=docker/dockerfile:1
FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
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"]
Multi-stage to leave the build tooling behind, a -slim base, dependency install split from source COPY for cache friendliness, dev dependencies pruned, and a .dockerignore keeping the context clean. Then confirm it with docker history myapp:1.4.2 and watch the fat layers disappear.
Wrapping up
Slim images come from a short, repeatable checklist: measure with docker history first, use multi-stage builds to drop the toolchain, pick a base you understand the tradeoffs of and pin it, keep a real .dockerignore, clean caches in the same RUN, and never bake a secret into a layer. Do those and the size problem mostly takes care of itself — and the security surface shrinks along with the bytes.
If you want worked reference setups to copy from rather than assemble by hand, there's a Docker stack toolkit with patterns for the common languages. Grab one as a starting point and adapt it to your service.
Top comments (0)