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 a handful of targeted changes, the Node API dropped to 420 MB and the Go service to 18 MB, with the same functionality, faster pulls, and a smaller attack surface. Here’s what I did.

Start by 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, which tells you which RUN step is the culprit. Often it’s a package install that was never cleaned up.

1. Use a smaller base image

This is the biggest single change you can make. Most teams default to ubuntu or node:latest without thinking about it. Those are full operating system images built for general use, and you don’t need most of what’s in them.

Base image Compressed 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 further, down to scratch or gcr.io/distroless/static. More on that later.

2. Use multi-stage builds

This probably helps compiled languages most, but it applies to interpreted ones too.

You use a full “builder” image to compile your code, then copy only the output into a lean “runtime” image. The compiler, build tools, and intermediate files all stay 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; distroless/static is under 3 MB. The final image contains your binary and the bare 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 a lot of Dockerfiles 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 version keeps the node_modules layer cached across builds unless the lockfile changes, so builds get faster with the 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/*

The same goes for npm, pip, and any other package manager that leaves a cache directory behind.

5. Use .dockerignore

If you don’t have a .dockerignore file, Docker sends your entire build context, including node_modules, .git, build outputs, and 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

With this file in place the build context gets much smaller, and you won’t accidentally ship 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 to 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 empty: no OS and no shell, so there’s nothing for an attacker to use.

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 and comes in at 10 to 20 MB depending on your binary.

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. Most images stay bloated because the Dockerfile was written once, shipped, and never revisited. Run docker history on your images and you’ll probably find a quick win.