Docker Images: From 1.2GB to Under 100MB Without Losing Sanity
Multi-stage builds, distroless bases, and layer hygiene. A practical guide to shrinking Docker images the right way.
A 1.2GB image takes forever to push, pulls slowly on every node, and doubles your storage bill. Here is the exact recipe I use to shrink images — and what to do only after measuring.
Start with multi-stage builds
Never ship your builder toolchain. Build in one stage, copy artifacts into a clean one.
# Stage 1: build
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: run
FROM node:22-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json .
RUN npm ci --omit=dev
EXPOSE 3000
CMD ["node", "dist/index.js"]
One FROM per stage, only what the runtime needs in the final stage.
Choose your base image deliberately
- Alpine (
node:22-alpine): tiny, but musl libc differences occasionally bite (native modules, DNS quirks). Fine for pure-JS apps. - Distroless (
gcr.io/distroless/static): no shell, no package manager, fewer attack surfaces — but debugging inside is harder. - Debian slim (
node:22-slim): glibc, familiar tooling, slightly larger. Use when native builds matter.
A rule of thumb: default to slim, not alpine for anything with native dependencies, and consider distroless for hardened, immutable services.
Layer hygiene is the real win
Each RUN, COPY, and ADD creates a layer. Reorder so the slowest-changing instructions come first — dependency files before source code. That way a one-line source change reuses all the cached dependency layers.
Never run apt-get upgrade or caches in the final image:
RUN apt-get update && apt-get install -y git \
&& rm -rf /var/lib/apt/lists/*
Measure, then trim
Don't guess. These tell you what is actually big:
docker history --no-trunc $IMAGE | head -20
docker image inspect $IMAGE | jq '.[0].RootFS.Layers' | head
Keep planned savings tables: "base swap −80MB, multi-stage −600MB, omit dev deps −120MB." If a trim doesn't move the number, drop it.
The wins in order
- Multi-stage build (usually the biggest, often −70%)
- Right base image
--omit=dev/--productiondependency install- Meaningful
.dockerignore(keeps build context small and secure) - Only copy artifacts you run
Summary
Compress the image, not the code. Measure the diff, ship the artifact, pull it in seconds. If your image is over 300MB and you shipped from a builder stage, you know where to start.