Your container image is 1.2 GB and could be 80

Leonardo Gurgitano5 minLeer en español

Concrete volumes stacked on top of one another, seen from below.
Concrete volumes stacked on top of one another, seen from below.

A large container image is not just an ugly number. It is download time on every deploy, on every new node in the cluster and on every cold start. And it is attack surface: every program you include and do not use is something somebody can take advantage of if they manage to run code inside.

Most images over a gigabyte got there through four specific decisions. Let us go through them one at a time, with what each change saves.

The starting point

A typical Node Dockerfile, of the kind you see every day:

FROM node:22

WORKDIR /app
COPY . .
RUN npm install
RUN npm run build

CMD ["node", "dist/server.js"]

It works. And it produces an image of about 1.1 GB. It has four problems, and none of them is obvious from reading it.

1. The final image includes everything needed to build

node:22 ships the complete operating system, the C compiler, Python, Git and the package manager. All of that is needed to build, and none of it to run.

The fix is a multi-stage build: one stage builds, another runs, and only what is needed gets copied across.

# ── Stage 1: build ──────────────────────────────────────
FROM node:22 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

# ── Stage 2: run ────────────────────────────────────────
FROM node:22-slim
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]

Everything you do not explicitly copy from one stage to the other is thrown away. The compiler, the source files, the npm cache and the development dependencies never reach the final image.

From 1.1 GB down to around 240 MB.

2. The order of the lines decides how long every build takes

Notice that in the example above COPY package*.json comes before COPY . .. That is not cosmetic.

Docker caches each instruction, and when one changes it invalidates that one and all the ones after it. In the original version, COPY . . comes before npm install: any change to any file in the project — a comma in a comment — invalidates the copy, and with it the dependency install. Every build reinstalls everything from scratch.

By copying only package.json and package-lock.json first, the install is reused as long as the dependencies do not change. Which is almost always.

On a medium-sized project that is the difference between a two-minute build and a ten-second one.

The same principle applies in other languages: COPY go.mod go.sum before the code in Go, COPY *.csproj before the rest in .NET, COPY requirements.txt in Python.

3. The base image ships an operating system you do not use

node:22-slim still includes a shell, a package manager and dozens of utilities. A distroless image ships only the language runtime and its libraries: no bash, no apt, no curl.

FROM gcr.io/distroless/nodejs22-debian12
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
CMD ["dist/server.js"]

From 240 MB down to around 130 MB. And something more important than the size: if somebody manages to run code inside the container, there is no shell for them to use. Many attack chains depend on being able to run sh or download a tool with curl. Here neither exists.

For a sense of scale among minimal bases: Alpine is around 26 MB, the Chainguard images around 20 MB, and scratch — literally nothing — 12.6 MB.

The real cost: you cannot get into the container to look around. docker exec ... sh does not work because there is no sh. That forces you to rely on logs and metrics, which is the right thing in production, but it changes how you debug.

4. You run as root without needing to

By default, the process inside the container runs as root. If somebody escapes the process, they start with maximum privileges.

# Distroless images ship an unprivileged user ready to use
USER nonroot

On an ordinary image, you create one:

RUN useradd --system --uid 10001 app
USER 10001

Use the number, not the name. Kubernetes can verify runAsNonRoot: true by looking at the numeric id; with a name it has to resolve it inside the image, and there are setups where that fails.

This does not reduce the size. It is the one-line change with the best ratio of effort to benefit on the whole list.

The result

# syntax=docker/dockerfile:1

FROM node:22 AS build
WORKDIR /app
# Dependencies first: the cache is reused as long as they do not change
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

FROM gcr.io/distroless/nodejs22-debian12
WORKDIR /app
COPY --from=build --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=build --chown=nonroot:nonroot /app/dist ./dist
USER nonroot
CMD ["dist/server.js"]
Size Build with cache
Original 1.1 GB ~2 min
Multi-stage 240 MB ~10 s
Distroless 130 MB ~10 s
Compiled to a native binary 20-80 MB ~10 s

The last row is for languages that compile to a single executable — Go, Rust, or .NET with Native AOT — where the final image can be scratch with one file inside.

How to find out what is taking up the space

Before optimising without knowing where the weight is, look at the layers:

docker history --no-trunc --format "{{.Size}}\t{{.CreatedBy}}" my-image:latest

Sorted by size, it is almost always the same things: the package manager cache, development dependencies that made it into production, or source files that got copied and stayed.

And a well-made .dockerignore prevents the problem at the source:

node_modules
.git
dist
*.log
.env*
coverage

Without it, COPY . . copies your entire .git with all its history, and your local node_modules — which may also contain binaries compiled for a different operating system that will break the image in confusing ways.

What I would do first

If you are only going to make one change: split it into two stages and put the dependency file before the code. That single change usually removes 80% of the weight and almost all of the build time.

The rest is incremental improvement. That one is the jump.

Comments