A while back I inherited a service whose Docker image was 1.2GB. Pulls were slow, the CI cache was useless, and the deploy step took long enough that people context-switched away and forgot about it. I got it down to about 180MB without changing a line of application code. Here's exactly what moved the needle, roughly in order of impact.
1. Multi-stage builds (the big one)
The single biggest win. The original Dockerfile built the app and shipped the entire build toolchain along with it — compilers, dev headers, the full package cache. None of that is needed at runtime.
# build stage — has all the heavy tooling
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# runtime stage — starts clean, copies only the artifact
FROM node:20-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
The runtime image never contains the build tools. That alone took roughly 500MB off.
2. Pick a smaller base
node:20 is Debian with everything. node:20-slim drops a couple hundred MB. If your app is a static binary (Go, Rust) you can go all the way to distroless or scratch and ship just the binary — no shell, no package manager, no OS to speak of. Smaller base = smaller image and a smaller attack surface, which your security team will also thank you for.
The trade-off: distroless has no shell, so docker exec ... sh won't work for debugging. Know that going in.
3. Order layers by how often they change
Docker caches layers top-down and invalidates everything after the first change. If you COPY . . before installing dependencies, every code change busts your dependency cache and reinstalls everything.
Copy your lockfile and install deps first, then copy the rest of the source:
COPY package*.json ./
RUN npm ci # cached until dependencies actually change
COPY . . # changes every commit, but deps stay cached
This didn't shrink the final image much, but it turned a 4-minute rebuild into a 20-second one.
4. Add a real .dockerignore
Without it, COPY . . drags your entire .git history, node_modules, local .env files, test fixtures, and CI logs into the build context — bloating the image and leaking things you don't want baked into a layer.
.git
node_modules
*.log
.env*
dist
coverage
5. Collapse and clean up RUN layers
Every RUN is a layer, and deleting files in a later layer doesn't shrink the earlier one. Install, use, and clean up in a single RUN:
RUN apt-get update \
&& apt-get install -y --no-install-recommends some-tool \
&& rm -rf /var/lib/apt/lists/*
The rm has to be in the same RUN as the apt-get, or the cache still ships in the layer beneath it.
The results
| Before | After | |
|---|---|---|
| Image size | 1.2 GB | ~180 MB |
| Cold pull | ~90s | ~12s |
| Cached rebuild | ~4 min | ~20s |
None of this is exotic — it's multi-stage, a slimmer base, layer order, .dockerignore, and cleaning up in place. But together they turn a deploy you dread into one you don't think about.
If you want the deeper reference — including the Docker errors these optimizations sometimes surface (no space left on device, cache-key failures, and friends) — I keep a full set:
- The Docker troubleshooting toolkit and the
no space left on deviceguide for when the build disk fills up mid-optimization
What's the smallest you've gotten a real production image (not a hello-world)? Always looking for tricks I haven't tried.
Top comments (0)