How to Cut Your Docker Image Size in Half

Bloated Docker images slow down CI pipelines, waste registry storage, and increase attack surface. Here's how to shrink them down without sacrificing functionality.

I recently audited the Docker images across a few projects I maintain. The numbers were embarrassing. One Node.js API was sitting at 1.4 GB. A simple Go binary was nearly 900 MB. Neither of them needed to be anywhere near that large.

After applying a handful of targeted techniques, the Node API dropped to 420 MB and the Go service went down to 18 MB. Same functionality. Faster pulls. Smaller attack surface. Here’s exactly what I did.

Start by actually measuring

Before optimizing anything, run this:

docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"

And to see what’s eating up space inside a specific image:

docker history my-app:latest

Each layer is listed with its size. This tells you exactly which RUN step is the culprit — often it’s a package install step that was never cleaned up.

1. Use a smaller base image

The single highest-leverage change you can make. Most teams default to ubuntu or node:latest without thinking about it. These are full operating system images designed for general use. You don’t need most of what’s in them.

Base imageCompressed size
ubuntu:24.04~29 MB
debian:bookworm~47 MB
node:22~141 MB
node:22-slim~62 MB
node:22-alpine~18 MB

For most workloads, the -slim variant is a safe starting point. Alpine is smaller still but uses musl libc, which can cause subtle compatibility issues with some native modules.

# Before
FROM node:22

# After
FROM node:22-slim

For Go, Rust, or any language that compiles to a static binary, you can go even further — down to scratch or gcr.io/distroless/static. More on that later.

2. Use multi-stage builds

This is probably the most impactful technique for compiled languages, but it applies to interpreted ones too.

The idea: use a full “builder” image to compile your code, then copy only the output into a lean “runtime” image. Everything used during the build — the compiler, build tools, intermediate files — gets left behind.

Here’s a Go example before and after:

# Before: single stage, ships the entire Go toolchain
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o server .
CMD ["./server"]
# After: builder stage + distroless runtime
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server .

FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
CMD ["/server"]

The golang:1.22 image is around 800 MB. The distroless/static image is under 3 MB. Your final image contains only your binary and the absolute minimum to run it.

The same pattern works for Node.js:

FROM node:22-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-slim AS runtime
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json .
CMD ["node", "dist/index.js"]

If you’re careful about only copying node_modules from a production install, you skip all your dev dependencies too.

3. Separate your npm install from your source copy

Layer caching is Docker’s biggest performance win, but most people don’t use it properly for dependencies.

# Wrong: invalidates the cache on every source file change
COPY . .
RUN npm ci

# Right: only reinstalls when package.json changes
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

The second approach keeps your node_modules layer cached across builds unless the lockfile changes. Faster builds, same result.

4. Clean up in the same layer

Every RUN instruction creates a new layer. If you install packages in one layer and delete the cache in the next, the original files are still baked into the earlier layer — Docker just adds a deletion record on top.

# Wrong: cache files survive in the apt layer
RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# Right: one layer, one cleanup
RUN apt-get update && apt-get install -y curl \
    && rm -rf /var/lib/apt/lists/*

Same applies to npm, pip, and any other package manager that leaves behind a cache directory.

5. Use .dockerignore

If you don’t have a .dockerignore file, Docker copies your entire build context — including node_modules, .git, build outputs, local config files — before filtering anything. This bloats the context transfer and can pull unnecessary files into your image.

A sensible default for a Node.js project:

node_modules
dist
.git
.env
.env.local
*.log
coverage
.DS_Store

Add this file and your build context will be significantly smaller. You’ll also avoid accidentally shipping secrets.

6. Don’t install dev dependencies in production

This one sounds obvious but I’ve seen it in production images more than I’d like to admit.

# Installs everything including devDependencies
RUN npm install

# Only production dependencies
RUN npm ci --omit=dev

For a typical project with a reasonable number of dev tools (TypeScript, ESLint, testing libraries), this alone can cut node_modules by 40–60%.

7. Use scratch or distroless for static binaries

If your app compiles to a fully static binary with no external shared library dependencies, you can use scratch as the base image — which is literally nothing. Zero OS, zero shell, zero attack surface.

FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o server .

FROM scratch
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

The final image contains exactly one file. It will be 10–20 MB depending on your binary, and there is nothing else to exploit in it.

If you need a CA bundle for making TLS requests, copy that too:

COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

Putting it together

Here’s the checklist I run through for any new Dockerfile:

  1. Is the base image the smallest that works? (-slim, -alpine, or distroless)
  2. Am I using multi-stage builds to keep build tools out of the runtime image?
  3. Are dependency files copied before source files, so layer caching works?
  4. Are package manager caches cleaned in the same RUN step that installs them?
  5. Does a .dockerignore exist and exclude node_modules, .git, and build artifacts?
  6. Am I only installing production dependencies?

None of these are difficult. The reason most images stay bloated is simply that the Dockerfile was written once, shipped, and never revisited. Run docker history on your images today. You’ll probably find a quick win.