DEV Community

Cover image for How I Cut a Docker Image From 1.2GB to 180MB
James Joyner for DevOps AI ToolKit

Posted on

How I Cut a Docker Image From 1.2GB to 180MB

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/*
Enter fullscreen mode Exit fullscreen mode

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:

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)