Skip to main content

Command Palette

Search for a command to run...

Container Image Optimization: Multi-Stage Builds

Published
7 min readView as Markdown
T

Welcome to TopperBlog! 👋

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

🎯 What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

Container Image Optimization: Multi-Stage Builds

Article Content

Container images have become the de facto standard for packaging and deploying applications. However, as development practices evolve and applications grow more complex, a critical problem has emerged: bloated container images that include unnecessary build dependencies, source code, and development tools in production deployments. This inefficiency leads to longer deployment times, increased security vulnerabilities, and higher storage costs.

Multi-stage builds offer an elegant solution to this problem, allowing developers to create lean, production-ready container images while maintaining a streamlined development workflow. In this comprehensive guide, we'll explore how multi-stage builds work, why traditional approaches fall short, and how to implement them effectively in modern TypeScript applications.

The 2026 Problem: Why Image Size Matters More Than Ever

As we move deeper into 2026, the container ecosystem faces unprecedented challenges. Cloud costs continue to rise, with container registry storage and data transfer fees becoming significant line items in infrastructure budgets. Security compliance requirements have tightened, with regulations demanding minimal attack surfaces and complete software bill of materials (SBOM) tracking.

The average Node.js application container image has ballooned to over 1.2GB, with TypeScript projects often exceeding 1.5GB when including all build dependencies. This bloat translates to:

  • Deployment delays: Pulling a 1.5GB image across a distributed cluster can take 3-5 minutes, compared to 20-30 seconds for a 150MB optimized image
  • Security vulnerabilities: Each additional package increases your attack surface, with build tools like webpack, TypeScript compiler, and development dependencies containing hundreds of potential CVEs
  • Cost implications: Organizations with hundreds of microservices can spend $50,000+ annually just on container registry storage and bandwidth
  • Developer friction: Slow CI/CD pipelines due to image build and push times reduce deployment frequency and developer productivity

Why Traditional Approaches Fail

Before multi-stage builds became standard, developers typically used one of three flawed approaches:

The Monolithic Dockerfile

The simplest but worst approach involves a single Dockerfile that installs all dependencies, builds the application, and runs it—all in one image:

FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]

This approach leaves all development dependencies, TypeScript source files, test files, and build tools in the final image. A typical result: 1.2GB+ images with 800+ npm packages, most completely unnecessary for runtime.

The External Build Script

Some teams moved builds outside Docker, building locally and copying only the artifacts:

FROM node:20
WORKDIR /app
COPY dist/ ./dist/
COPY node_modules/ ./node_modules/
CMD ["node", "dist/index.js"]

This creates inconsistency between development and production environments, the classic "works on my machine" problem. Build reproducibility suffers, and CI/CD pipelines become complex with external build orchestration.

The Two-Dockerfile Pattern

More sophisticated teams maintained separate Dockerfiles for build and runtime:

# Dockerfile.build
FROM node:20
WORKDIR /app
COPY . .
RUN npm install && npm run build

# Dockerfile.runtime
FROM node:20-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]

While better, this approach requires complex build scripts to coordinate between Dockerfiles, increases maintenance burden, and still lacks the elegance of a unified solution.

The Modern Solution: Multi-Stage Builds with TypeScript

Multi-stage builds, introduced in Docker 17.05, allow multiple FROM statements in a single Dockerfile. Each FROM begins a new build stage, and you can selectively copy artifacts between stages. The final image contains only what you explicitly include from previous stages.

Here's a production-ready multi-stage build for a TypeScript application:

# Stage 1: Dependencies
FROM node:20-alpine AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && \
    cp -R node_modules /prod_node_modules && \
    npm ci

# Stage 2: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json tsconfig.json ./
COPY --from=dependencies /app/node_modules ./node_modules
COPY src ./src
RUN npm run build && \
    npm prune --production

# Stage 3: Runtime
FROM node:20-alpine AS runtime
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001
WORKDIR /app
COPY --from=dependencies /prod_node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
USER nodejs
EXPOSE 3000
CMD ["node", "dist/index.js"]

This approach delivers a final image of approximately 150-200MB compared to 1.2GB+ with traditional methods—an 85% reduction.

Breaking Down the Stages

Stage 1: Dependency Separation

The dependencies stage installs both production and development dependencies but crucially separates them. We use npm ci for reproducible installs and create a clean copy of production dependencies before installing dev dependencies. This separation is key to the final image size reduction.

Stage 2: Build Optimization

The builder stage focuses solely on compilation. It copies only necessary source files (not tests, documentation, or configuration files unrelated to the build). After building, it prunes development dependencies, though these won't appear in the final image anyway.

Stage 3: Minimal Runtime

The runtime stage uses only production dependencies and compiled artifacts. It also implements security best practices: running as a non-root user, using the minimal Alpine base image, and exposing only necessary ports.

Common Pitfalls and How to Avoid Them

Pitfall 1: Copying Unnecessary Files

Problem: Using COPY . . copies everything, including .git, node_modules, test files, and documentation.

Solution: Create a comprehensive .dockerignore file:

node_modules
npm-debug.log
.git
.gitignore
README.md
.env
.env.*
dist
coverage
.vscode
.idea
*.test.ts
*.spec.ts
__tests__

Pitfall 2: Cache Invalidation

Problem: Copying package.json and source code together invalidates the dependency cache on every code change.

Solution: Copy and install dependencies before copying source code. Docker caches each layer, so unchanged dependencies won't reinstall.

Pitfall 3: Using Latest Tags

Problem: FROM node:latest creates non-reproducible builds that break unexpectedly.

Solution: Pin specific versions: FROM node:20.11.0-alpine3.19. Update deliberately through your CI/CD process.

Pitfall 4: Running as Root

Problem: Running containers as root violates security best practices and fails many compliance checks.

Solution: Always create and switch to a non-privileged user in your runtime stage.

Pitfall 5: Ignoring Layer Optimization

Problem: Each RUN command creates a new layer. Multiple RUN commands for related operations create unnecessary layers.

Solution: Chain related commands with && and use multi-line formatting for readability:

RUN apk add --no-cache \
    python3 \
    make \
    g++ && \
    npm ci && \
    apk del python3 make g++

Best Practices for Production

  1. Use Alpine-based images: Alpine Linux images are 5-10x smaller than Debian-based alternatives
  2. Implement health checks: Add HEALTHCHECK instructions for container orchestration
  3. Leverage build arguments: Use ARG for build-time configuration without hardcoding values
  4. Enable BuildKit: Docker BuildKit provides better caching, parallel builds, and improved performance
  5. Scan for vulnerabilities: Integrate tools like Trivy or Snyk into your CI/CD pipeline
  6. Version your images properly: Use semantic versioning and Git commit SHAs for traceability
  7. Document your stages: Add comments explaining each stage's purpose for team maintainability

Frequently Asked Questions

Q: How much smaller will my images actually be? A: Typically 70-85% smaller. A 1.2GB TypeScript application image usually reduces to 150-250MB with proper multi-stage builds, depending on your production dependencies.

Q: Do multi-stage builds slow down my build process? A: Initially, builds may take slightly longer due to multiple stages. However, Docker's layer caching means subsequent builds are often faster than traditional approaches, especially when only source code changes.

Q: Can I use multi-stage builds with monorepos? A: Yes, but it requires careful planning. Use build contexts and .dockerignore strategically to copy only relevant packages. Consider tools like Turborepo or Nx for optimized monorepo Docker builds.

Q: Should I use multi-stage builds for development? A: Not necessarily. Multi-stage builds optimize for production. For development, a simpler Dockerfile with hot-reloading and all dev tools is often more practical. Use Docker Compose to manage different configurations.

Q: How do I debug issues in intermediate stages? A: Use the --target flag to build up to a specific stage: docker build --target builder -t debug-image .. You can then run and inspect that stage's output.

Q: What about native dependencies that need compilation? A: Install build tools (python, make, g++) in your builder stage, compile native modules, then copy only the compiled node_modules to the runtime stage. Remove build tools before the final stage.

Q: Can I share stages between multiple Dockerfiles? A: Yes, using external images. Build your common stages, push them to a registry, and reference them with FROM your-registry.com/base-builder:latest AS builder in other Dockerfiles.

Conclusion

Multi-stage builds represent a fundamental shift in how we approach container image creation. By separating build-time and runtime concerns, we achieve dramatically smaller images, improved security postures, and faster deployment cycles—all without sacrificing developer experience or build reproducibility.

For TypeScript applications in 2026, multi-stage builds aren't just a best practice; they're a necessity. The combination of reduced cloud costs, improved security compliance, and faster deployment times delivers immediate ROI that justifies the initial learning curve.

Start by implementing multi-stage builds in one service, measure the improvements, and gradually roll out the pattern across your infrastructure. Your future self—and your infrastructure budget—will thank you.


Metadata

```json { "seo_title": "Container Image Optimization: Multi-Stage Builds Guide", "meta_description": "Learn how multi-stage Docker builds reduce TypeScript container images by 85%. Complete guide with examples, pitfalls, and best practices for 2026.", "primary_keyword": "multi-stage builds", "secondary_keywords": [ "container image optimization", "Docker multi-stage builds", "TypeScript Docker optimization", "reduce Docker image size", "Docker best practices", "container security", "Alpine Docker images", "Docker layer caching" ], "tags": [ "Docker", "TypeScript", "DevOps", "Container Optimization", "CI/CD", "Security", "Cloud Infrastructure" ] }