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
*.log3. 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 --humanThe 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.