Build Wide, Ship Narrow: How to Slim Down Your Production Containers with Multi-Stage Builds and Distroless Images

How many times have you looked at the size of your production Docker images and winced? You start with a simple 50-line Node.js or Go application, and by the time you've pulled in your base image, installed your build-time dependencies, and compiled your assets, you’re looking at a bloated 1.2 GB container image.

This isn't just an aesthetic issue for those of us obsessed with efficiency. It’s a massive security and operational bottleneck. Huge images mean slower deployment pipelines, increased cloud storage costs, longer cold-start times in serverless environments like AWS Fargate or Google Cloud Run, and a massive attack surface. Your production container shouldn't ship with a C compiler, a package manager, or debugging tools.

Today, we're diving deep into the philosophy of "Build Wide, Ship Narrow." I’m going to show you exactly how to structure your development workflow and build pipelines so you can pull in everything you need during the build phase (Build Wide) while shipping absolutely nothing but the bare essentials to production (Ship Narrow). We'll look at concrete multi-stage Dockerfiles, discuss the role of Distroless images, and explore how this paradigm radically improves both your deployment speed and your security posture.

Understanding the Paradigm: Build Wide vs. Ship Narrow

To understand why this is a game-changer, let's break down the two opposing forces in containerization:

The "Build Wide" Phase

During compilation, dependency resolution, and testing, your environment needs to be incredibly accommodating. You need access to SDKs, compilers (like gcc or g++), package managers (npm, pip, cargo), header files, and testing libraries. If you try to constrain your build environment too much, you end up wasting hours fighting dependency conflicts or missing compilation tools. "Building Wide" means embracing a rich environment with all the tools required to build your artifact.

The "Ship Narrow" Phase

Once you have your compiled binary, your bundled JavaScript, or your execution-ready Python bytecode, the rules completely change. In production, your container needs exactly two things: your runtime environment (or not even that, if you're shipping a compiled Go/Rust binary) and your compiled application code. Shipping tools like curl, apt, or sh in your production container is an open invitation to attackers who exploit remote code execution (RCE) vulnerabilities to download and execute malicious payloads. "Shipping Narrow" means stripping away everything except the bare essentials required to run your app.

The Magic of Multi-Stage Builds

Historically, developers had to maintain separate Dockerfiles—like Dockerfile.dev and Dockerfile.prod—and write complex bash scripts to copy build artifacts out of one container and inject them into another.

Multi-stage builds, introduced in Docker 17.05, solved this elegantly. They allow you to use multiple FROM statements in a single Dockerfile. Each FROM instruction begins a new stage of the build, using a different base image. Crucially, you can selectively copy artifacts from one stage to another, leaving behind everything you don't want in your final image.

Let’s look at a practical, real-world example. We'll start with a modern Node.js application that uses TypeScript. To run in production, we need to compile the TypeScript to JavaScript, install our production dependencies, and discard our development dependencies and build tools.

A Classic "Bloated" Node.js Dockerfile

Here is what many developers end up shipping to production:

FROM node:20-alpine

WORKDIR /app

# Copy all files
COPY . .

# Install all dependencies (including devDependencies)
RUN npm install

# Compile TypeScript
RUN npm run build

EXPOSE 3000

CMD ["node", "dist/index.js"]

What's wrong with this? It contains TypeScript source files, devDependencies like Webpack, Jest, and ESLint, and the entire local npm cache. This image could easily top 800 MB.

The "Build Wide, Ship Narrow" Approach

Now, let's rewrite this using a multi-stage approach. We will build "wide" in the first stages and ship "narrow" in the final stage.

# Stage 1: The Builder (Build Wide)
FROM node:20-alpine AS builder

WORKDIR /app

# Copy dependency manifests
COPY package*.json ./

# Install ALL dependencies (including TypeScript and compiler tooling)
RUN npm ci

# Copy source code and configuration
COPY . .

# Compile TypeScript to JavaScript (generates ./dist)
RUN npm run build

# Stage 2: The Runtime Dependency Installer (Narrowing down)
FROM node:20-alpine AS dependency-cleaner

WORKDIR /app

COPY package*.json ./

# Install ONLY production dependencies, ignoring devDependencies
RUN npm ci --only=production

# Stage 3: The Runner (Ship Narrow)
FROM node:20-alpine AS runner

WORKDIR /app

# Bring in the clean node_modules from Stage 2
COPY --from=dependency-cleaner /app/node_modules ./node_modules

# Bring in only the compiled JS code from Stage 1
COPY --from=builder /app/dist ./dist
COPY package*.json ./

# Run as a non-root user for security
USER node

EXPOSE 3000

ENV NODE_ENV=production

CMD ["node", "dist/index.js"]

By splitting the process into three stages, we’ve managed to completely isolate the TypeScript compiler and all development dependencies in the builder stage. The final runner stage only contains the production-ready compiled code and runtime dependencies. The resulting image size can drop by up to 75%!

Leveling Up: Going "Distroless"

If you want to take "Ship Narrow" to its absolute limit, Alpine Linux might not even be narrow enough. Enter Distroless images, pioneered by Google.

Distroless images contain only your application and its runtime dependencies. They do not contain package managers, shells (like bash or sh), or any of the standard debugging utilities you would expect in a typical Linux distribution.

Let's look at how to build a highly secure Go application using a multi-stage build that compiles inside a heavy Golang SDK container, but ships inside a completely distroless container.

# Stage 1: Build Wide (Heavyweight build environment)
FROM golang:1.22-alpine AS builder

# Install build-essential tools if our Go app uses CGO
RUN apk add --no-cache git gcc musl-dev

WORKDIR /src

# Leverage Docker cache for dependencies
COPY go.mod go.sum ./
RUN go mod download

COPY . .

# Compile a static binary
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/app ./cmd/server

# Stage 2: Ship Narrow (Distroless runtime)
FROM gcr.io/distroless/static-debian12:latest-amd64

# Copy our statically compiled binary
COPY --from=builder /bin/app /bin/app

# Run as a non-root user (built into the distroless image)
USER nonroot:nonroot

EXPOSE 8080

ENTRYPOINT ["/bin/app"]

In this example, our builder image is over 800 MB, packed with GCC, Git, and the entire Go toolchain. The final image? Under 15 MB.

Moreover, because there is no shell (/bin/sh) in the distroless image, an attacker who finds a vulnerability in your web application cannot run arbitrary shell scripts or install malicious packages via curl. You have effectively locked down your runtime environment.

DevOps Impact: Why This Matters to Your Team

Adopting the "Build Wide, Ship Narrow" philosophy isn't just about showing off small numbers in your terminal. It has compounding benefits across your entire software delivery lifecycle:

  • Lightning-Fast CI/CD: Smaller images take drastically less time to push to your container registries (like AWS ECR or Docker Hub) and even less time for your Kubernetes nodes or serverless hosts to pull down. This slashes deploy times from minutes to seconds.
  • Reduced Attack Surface: Traditional container scanners (like Trivy, Grype, or Snyk) flag vulnerabilities in system libraries (like openssl, curl, or glibc). By shipping distroless or ultra-minimal images, you eliminate 95% of these CVEs, saving your security team hours of triage.
  • Lower Infrastructure Costs: Bandwidth and storage aren't free. Shipping gigabyte-scale containers daily to multiple environments quietly racks up significant cloud storage bills and data transfer fees.

Conclusion

The "Build Wide, Ship Narrow" pattern is one of the highest-leverage practices you can adopt in modern cloud-native engineering. It bridges the gap between developer productivity and operational rigor. By using multi-stage builds, you can leverage rich, unrestricted environments to build your applications, while guaranteeing that your production environments remain lean, lightning-fast, and secure.

What does your team's Docker configuration look like? Have you made the jump to Distroless, or are you still relying on bloated base images? Let me know in the comments below, or share this article with your team's DevOps lead!

Until next time, keep your builds wide and your shipments narrow.

Post a Comment

Previous Post Next Post