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.

Measure first
Find out what you have.
$ docker images myapp
$ docker history myapp:latestdocker 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-alpineThe 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
testsThis 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.
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.
| Step | Image size |
|---|---|
Original (node:22, single stage) | 1.42 GB |
| Slim base image | 512 MB |
| Multi-stage build | 298 MB |
.dockerignore and production dependencies only | 187 MB |
| Cleaned package cache | 164 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?
Does squashing layers help?
A short checklist
- Use a slim or distroless base image
- Build in one stage and run in another
- Add a
.dockerignore - Install and clean up in the same
RUN - Copy dependency files before the source
- Run as a non-root user