A smaller image downloads faster, starts sooner, and exposes fewer packages. The goal is not to make the Dockerfile clever; it is to make the runtime artifact contain exactly what the application needs.
Measure before changing the base image
Record compressed size, pull time, startup time, vulnerability count, and memory behavior. An image that is smaller but slower to start or harder to patch is not automatically better.
The symptom
Our runners spent more time pulling images than running the tests. The image had grown gradually: build tools, cached package managers, debugging utilities, and source files had all become part of the production artifact. Nobody had made one bad decision; the artifact had simply accumulated history.
The first attempt switched to a smaller base image and broke native modules in production. That failure was useful. It showed that size was not the only constraint and gave us a concrete list of runtime libraries the application actually required.
Build once, run a smaller artifact
This example is for a Node-hosted Next.js service with output: standalone enabled; it is not the deployment shape for a static export. Keeping builder and runtime on the same Debian family avoids surprising native-module ABI differences. Static exports should publish the out/ directory to object storage and a CDN instead.
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build --chown=node:node /app/public ./public
COPY --from=build --chown=node:node /app/.next/standalone ./
COPY --from=build --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"]Do not delete runtime truth
Use dependency tracing and a smoke test to confirm certificates, timezone data, native libraries, and non-root permissions still work. Hidden runtime dependencies become production incidents with excellent timing.
The resolution
We moved compilation into a builder stage, copied only the traced runtime output, tightened `.dockerignore`, and added an image-size budget to CI. The final image was smaller, but the real improvement was that a future dependency could not enter silently without somebody seeing the budget change.
Define the artifact contract
The runtime stage should contain the app, production dependencies, certificates, and the shared libraries those dependencies actually need—nothing from the builder merely because it was nearby. Keep builder and runtime on compatible libc and CPU architectures, run as a non-root user, and test the final image with the same startup command used in production. Promote by digest so the artifact scanned in CI is the artifact deployed later.
A small image that cannot start is not an optimization. It is just a more efficient way to deliver an incident.
The runtime test that matters
- Keep a strict .dockerignore
- Order stable dependency layers before source layers
- Use multi-stage builds
- Run as a non-root user
- Scan the final image, not only the builder