Skip to content
Case Files
PUBLIC2026.07.12·2 MIN READ

Your Docker Image Is 900MB. It Doesn't Need to Be.

A multi-stage build and a smaller base usually cut an image by ten times or more. Here's what's actually taking up the space, and how to get rid of it.

  • #docker
  • #containers
  • #ci-cd

I once took over a service whose Docker image was 1.4 gigabytes. It was a Node app. The actual code was maybe two megabytes. Everything else was junk: compilers, dev dependencies, apt caches, a couple of duplicate copies of things, all getting shipped to production on every deploy.

Big images cost you in ways that add up. Slower to push, slower to pull, slower to cold start, and every extra megabyte is one more thing for a scanner to flag. Most of the time you fix it with the same two changes.

First, look at what’s in there

You can’t cut what you can’t see. This command changed how I think about images:

docker history --no-trunc your-image:latest

It lists every layer and its size. Run it on a bloated image and you’ll spot the problem in a few seconds. Usually it’s a RUN that installed build tools, or a COPY . . that pulled in your whole .git folder and node_modules.

Change one: multi-stage builds

Here’s the idea. The tools you need to build an app aren’t the tools you need to run it. A compiler builds your binary. It has no reason to be in the container that serves traffic.

Multi-stage builds let you build in a heavy image and then copy only the result into a clean, small one:

# 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

# run stage, starts fresh and takes only what it needs
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

The final image never sees your dev dependencies, your source, or your build cache. It gets the compiled output and the production packages, nothing else. That change on its own often takes an image from 900MB to under 150.

Change two: a smaller base, and a .dockerignore

node:20 is Debian with everything on it. node:20-slim drops a few hundred megabytes you’ll never touch. For compiled languages you can go further. A Go binary in gcr.io/distroless/static can come in under 20MB total, because there’s no shell and no package manager, just your binary and the certs it needs.

Write a .dockerignore before anything else too. Without one, COPY . . drags in .git, node_modules, logs, and sometimes secrets:

.git
node_modules
*.log
.env
dist

Why the copy order matters

Look at where package*.json gets copied in the Dockerfile above. It’s copied and installed before the rest of the source, on purpose. Docker caches each layer and only rebuilds one when its inputs change. Your dependencies barely change. Your code changes constantly. Copy the lockfile and install first, and Docker reuses the cached npm ci layer on every build where you only touched app code. Get the order wrong and you reinstall everything every time.

None of this is fancy. Two FROM lines, a slimmer base, a .dockerignore. Fifteen minutes of work that keeps paying off on every deploy.


Back to Case Files