One of the most common things we find in DevOps audits is a several-hundred-megabyte — sometimes several-gigabyte — image for an application that is itself only a few megabytes.

Why image size matters

  • Deploy time: every time a new node comes up, it has to pull the whole image.
  • Cost: moving images between registry and cluster burns real bandwidth.
  • Security: every package in the final image is a potential vulnerability. Compilers, build tools and package managers have no business in production.

The multi-stage pattern

The idea is simple: build in one stage, and carry only the output into the next.

# ---- build stage ----
FROM node:22-alpine AS builder
WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build && npm prune --omit=dev

# ---- runtime stage ----
FROM node:22-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production
RUN addgroup -S app && adduser -S app -G app

COPY --from=builder --chown=app:app /app/node_modules ./node_modules
COPY --from=builder --chown=app:app /app/dist ./dist
COPY --from=builder --chown=app:app /app/package.json ./

USER app
EXPOSE 3000
CMD ["node", "dist/server.js"]

The builder stage installs whatever it needs. The runner stage takes only three things: production dependencies, build output and the package file. The compiler, the npm cache and the source code never reach the final image.

Four details that make the real difference

1. Order layers for caching. COPY package*.json comes before COPY . . so npm ci doesn't re-run while dependencies are unchanged.

2. Always write a `.dockerignore`. Without one, your local node_modules and .git get pulled into the build context and everything slows down:

node_modules
.git
.env*
dist
*.log

3. Run as a non-root user. Docker defaults to root. If an attacker reaches the container, this is the difference between limited access and total access.

4. Pin your versions. node:22-alpine beats node:latest, but node:22.11-alpine is genuinely reproducible.

Measure it

Check before and after:

docker images | grep my-app
docker history my-app:latest --human

The second command shows which layer consumed what. There is almost always one surprising layer that accounts for 60% of the size.

The next step

Once size is under control, add vulnerability scanning to the pipeline. Tools like Trivy run in seconds and can fail the build on a critical finding — which is exactly where it should fail.