portfolio Anshul Bisen
ask my work

Docker multi-stage builds that actually make your images small

Our production Docker image was 1.2 GB. After an afternoon of optimization it was 89 MB. Here is the exact Dockerfile evolution.

I ran docker images and stared at the number: 1.2 GB. Our production Docker image for a Next.js application was 1.2 gigabytes. It took four minutes to push to the registry, three minutes to pull on deploy, and was using a node:20 base image that included Python, gcc, make, and about 400 MB of system libraries we never touched. This was embarrassing. I blocked off an afternoon and committed to getting the image under 100 MB.

The kind of infrastructure that teaches you by breaking.

A lot of my month-one leadership came through infrastructure choices that looked small from the outside. It also builds on what I learned earlier in “Why we picked React Server Components over a separate API layer.” I was building the muscle memory that later fed the infrastructure and ctrlpane projects at home: reproducible defaults, cheap feedback loops, and enough observability that I did not need to guess under pressure.

The infrastructure mess that made the lesson stick.

Iteration 1: The Naive Dockerfile

Our starting Dockerfile was the one you get when you search “dockerize next.js app.” Copy everything, install everything, build, run. Simple and enormous.

# Iteration 1: 1.2 GB
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
  • The node:20 base image is 1.1 GB on its own. It includes build tools, Python, and system libraries meant for compiling native modules.
  • COPY . . copies node_modules if they exist locally, source files, git history, test files, documentation.
  • npm install runs without —production, installing devDependencies that the production runtime never needs.
  • The entire build context (source + dependencies + build output) persists in the final image.

Iteration 2: Slim Base Image

Switching from node:20 to node:20-slim drops the base image from 1.1 GB to 200 MB. The slim variant excludes build tools and system libraries. If you need native modules that compile during npm install, you add the specific build tools instead of carrying the entire Debian distribution.

# Iteration 2: 580 MB
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]

Two changes cut the image nearly in half. The slim base saved 900 MB, and separating package.json copy from source copy enables Docker layer caching. Dependencies only reinstall when the lockfile changes, not on every source edit.

Iteration 3: Multi-Stage Build

The multi-stage pattern is where the real savings happen. Separate the build environment from the runtime environment. The build stage has all the tools needed to compile the application. The runtime stage has only what is needed to run it.

# Iteration 3: 210 MB
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
FROM node:20-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

The runner stage only contains the standalone Next.js output, static assets, and public files. No node_modules (Next.js standalone bundles its own dependencies), no source code, no build tools. The image dropped from 580 MB to 210 MB.

Iteration 4: Distroless Final Stage

The node:20-slim image still includes a shell, package manager, and various system utilities. For a production image that only runs node server.js, none of these are needed. Google’s distroless Node.js image contains only the Node.js runtime and the bare minimum to execute JavaScript.

# Iteration 4: 89 MB
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
FROM node:22-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
FROM gcr.io/distroless/nodejs22-debian12 AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["server.js"]

From 1.2 GB to 89 MB. A 93% reduction. The distroless image has no shell, no package manager, no system utilities. You cannot exec into the container and poke around, which is actually a security benefit. If an attacker exploits a vulnerability in the application, there is no shell to spawn and no tooling to pivot with.

The CI Layer Caching Strategy

Small images are only half the story. Build speed matters just as much for CI throughput. The key is structuring the Dockerfile so that the most frequently changing layers are at the bottom.

  • Layer 1 (deps): Only rebuilds when package.json or the lockfile changes. Cached across most builds.
  • Layer 2 (build): Rebuilds on every source change but starts from cached dependencies.
  • Layer 3 (runner): Always rebuilds because it copies from the build stage, but it copies very little.

With Docker BuildKit and GitHub Actions cache, our CI builds run in 90 seconds when only source code changes, compared to 8 minutes with the naive Dockerfile. The dependency layer only rebuilds once or twice a week when we update packages.

Homelab, but treated like a real environment.

That was the pattern of my first months at FinanceOps: I did not have management scar tissue yet, so I earned trust by making technical decisions that stayed boring under pressure. The same bias toward strict defaults still shows up in portfolio, pipeline-sdk, and dotfiles today.

Do not accept a 1 GB Docker image because the quick start tutorial gave you a five-line Dockerfile. Multi-stage builds are not optional for production. They are the minimum standard.

The image size reduction had cascading benefits we did not anticipate. Smaller images meant faster pulls, which meant faster deployments, which meant we could ship more frequently with less risk. The multi-stage build pattern also enforced a clean separation between build dependencies and runtime dependencies, which caught several cases where development-only packages were leaking into production. Container hygiene is not just about disk space — it is about deployment confidence and operational clarity.