Your Docker image is too big. Here is why

Five causes, five mechanical fixes and an image that went from 1.4 GB to 164 MB.

Your Docker image is too big. Here is why

A simple web service should fit in an image of about 100 megabytes. Many are over a gigabyte. Large images are slow to build, slow to pull and carry hundreds of packages that you do not use and must still patch. The causes are few, and the fixes are mechanical.

Every megabyte is pulled by every server, on every deploy.
Every megabyte is pulled by every server, on every deploy.

Measure first

Find out what you have.

$ docker images myapp
$ docker history myapp:latest

docker history lists each layer and its size. One or two lines usually account for most of the image. Start there.

Cause 1: a heavy base image

The line FROM node:22 pulls a full Debian system with compilers, version control tools and documentation. It is about 1.1 GB before your code is added.

# 1.1 GB
FROM node:22

# 240 MB
FROM node:22-slim

# 160 MB
FROM node:22-alpine

The slim variant is the safe default. Alpine is smaller and uses a different C library, which can cause trouble with some native modules. Test before you switch.

Cause 2: build tools in the final image

You need a compiler to build the application. You do not need one to run it. A multi-stage build uses one image to build and copies only the result into a second, clean image.

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

FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

The first stage has the dev dependencies and the source. The final image has the production dependencies and the compiled output, and nothing else.

For compiled languages, the effect is larger. A Go or Rust binary can be copied into a base image of a few megabytes.

FROM golang:1.24 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server

FROM gcr.io/distroless/static
COPY --from=build /app /app
ENTRYPOINT ["/app"]

That image is about 15 MB.

The final stage should contain what the process needs at run time, and you should be able to list it from memory.

Tomás Rivera

Cause 3: files that should never be copied

COPY . . copies everything in the folder: the .git directory, local node_modules, test data, log files and sometimes secrets. Add a .dockerignore file.

.git
node_modules
dist
*.log
.env*
coverage
tests

This also makes builds faster, because less data is sent to the build engine.

Cause 4: layers that remember deleted files

Each instruction creates a layer, and a layer never gets smaller. If you download a file in one RUN and delete it in the next, the image still contains it.

# Bad: the package cache stays in the first layer
RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# Good: install and clean in one layer
RUN apt-get update \
 && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*

The flag --no-install-recommends alone often saves a hundred megabytes.

Cause 5: poor use of the cache

This one affects speed more than size. Docker reuses a layer if nothing before it has changed. Put the things that change least at the top.

- COPY . .
- RUN npm ci
+ COPY package*.json ./
+ RUN npm ci
+ COPY . .

With the second order, a change to your source code reuses the dependency layer. Builds go from minutes to seconds.

💡
Look inside. The tool dive shows each layer and the files it added. It is the fastest way to find a forgotten folder.

What we got

We applied these steps to one of our own services.

StepImage size
Original (node:22, single stage)1.42 GB
Slim base image512 MB
Multi-stage build298 MB
.dockerignore and production dependencies only187 MB
Cleaned package cache164 MB

The deploy time fell from four minutes to fifty seconds, and the security scanner's report went from 312 findings to 19.

Smaller is also safer

Every package in the image is something an attacker can use and something you have to patch. An image with no shell and no package manager gives an intruder very little to work with. Run the process as a user that is not root, as the USER node line above does.

Should I use Alpine?

For Go and Rust, or for static files, yes. For Node and Python with native modules, try it and run your tests. The slim Debian images are nearly as small and cause fewer surprises.

Does squashing layers help?

It can reduce size a little, and it removes the benefit of shared layers between images. A multi-stage build gets the same result in a cleaner way.

A short checklist

  1. Use a slim or distroless base image
  2. Build in one stage and run in another
  3. Add a .dockerignore
  4. Install and clean up in the same RUN
  5. Copy dependency files before the source
  6. Run as a non-root user

Great! Check your inbox and click the link to confirm.