← Back to Blog

Reducing Docker Image Size Without Hiding Dependencies

A focused container optimization guide covering build context, multi-stage builds, runtime dependencies, and measurable image budgets.

Cover image for Reducing Docker Image Size Without Hiding Dependencies
DockerContainersSecurityCI/CD

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.

dockerfile
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.

Build tools stay in the builder; the tested runtime artifact is scanned and promoted by digest without rebuilding.
Build tools stay in the builder; the tested runtime artifact is scanned and promoted by digest without rebuilding.

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
← Back to BlogHome