Engineering Blog
11 min read
DevOpsJanuary 3, 202511 min

How We Reduced Our Next.js Docker Image from 2.1GB to 180MB

TL;DR

We reduced our Next.js Docker image by 91% (2.1GB to 180MB) using multi-stage builds, Alpine base, standalone mode, and layer caching. Results: 8x faster deploys, 80%+ lower registry costs, pod startup dropped from 90s to 12s.

P

Pasha S

Engineering

How We Reduced Our Next.js Docker Image from 2.1GB to 180MB

We deploy our Next.js frontend to Kubernetes multiple times per day. A few months ago, each deploy was painful. Our Docker image had ballooned to 2.1GB. Pull times exceeded 6 minutes. Registry costs were climbing. Security scans flagged 47 vulnerabilities from packages we didn't even need.

We got it down to 180MB. Here's the complete step-by-step journey.

The Starting Point: What Went Wrong

Our original Dockerfile looked like every tutorial on the internet:

FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]

Simple. Elegant. And absolutely terrible for production.

This creates a 2GB+ disaster:

Problem Impact
Full Node.js Debian image ~1GB base before your code
COPY . . before install Busts cache on every file change
npm install includes devDeps Hundreds of MB of build tools
No .dockerignore Copies .git, node_modules, tests
Build artifacts left in image .next/cache, source maps
Root user Security vulnerability

Result: 2.1GB image, 47 security vulnerabilities, 6+ minute pulls.

We couldn't keep shipping like this.


Step 1: Switch to Alpine Base Image

The single biggest win. One line change that cuts 860MB.

Alpine Linux is a security-focused, lightweight distribution. The Node.js Alpine image is ~140MB vs ~1GB for Debian-based images.

# Before: ~1GB base
FROM node:20

# After: ~140MB base
FROM node:24-alpine

Alpine uses musl instead of glibc, which can cause issues with some native modules. Add compatibility layer to be safe:

FROM node:24-alpine
RUN apk add --no-cache libc6-compat

Impact: -860MB immediately.


Step 2: Multi-Stage Builds

This is the architecture that makes everything else possible. We split the build into three distinct stages:

+------------------+     +------------------+     +------------------+
|     Stage 1      |     |     Stage 2      |     |     Stage 3      |
|      deps        | --> |     builder      | --> |     runner       |
|                  |     |                  |     |                  |
|  Install prod    |     |  Install all     |     |  Copy only       |
|  dependencies    |     |  deps + build    |     |  what's needed   |
+------------------+     +------------------+     +------------------+
      ~200MB                   ~900MB                   ~180MB

Each stage starts fresh. Only the final stage goes to production. All the build cruft stays behind.

Stage 1: Dependencies

FROM node:24-alpine AS deps

RUN apk add --no-cache libc6-compat
RUN corepack enable && corepack prepare [email protected] --activate

WORKDIR /app

COPY package.json pnpm-lock.yaml ./
RUN pnpm install --prod --frozen-lockfile

Key points:

  • --prod flag: Only production dependencies
  • --frozen-lockfile: Fails if lockfile is outdated (reproducible builds)
  • Copy only package.json and lockfile first for layer caching

Stage 2: Builder

FROM node:24-alpine AS builder

RUN corepack enable && corepack prepare [email protected] --activate

WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .
COPY .env.build ./.env

RUN pnpm install --frozen-lockfile

ENV NODE_ENV=production
RUN pnpm build

This stage:

  • Copies prod deps from stage 1 (layer cache hit)
  • Installs ALL deps (including dev) for the build
  • Runs the actual Next.js build
  • Everything here stays in this stage

Stage 3: Runner (Production)

FROM node:24-alpine AS runner

WORKDIR /app

RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nextjs

ENV NODE_ENV=production
ENV PORT=3000

COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public

USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

This stage starts completely fresh:

  • New Alpine image (no build cruft)
  • Non-root user for security
  • Only three directories copied from builder

Step 3: Enable Next.js Standalone Mode

This is the secret weapon. In next.config.js:

module.exports = {
  output: 'standalone',
}

Next.js traces your actual imports and bundles only what's needed into a self-contained server:

.next/standalone/
├── server.js           # Self-contained server
├── node_modules/       # Only traced dependencies (~30MB)
└── [your app code]

Before standalone: Copy 500MB+ of node_modules After standalone: Copy ~30MB of traced dependencies

Directory Contents Typical Size
.next/standalone Server + traced deps ~50MB
.next/static CSS/JS bundles ~20MB
public Static assets Varies

No full node_modules. No source files. No devDependencies.


Step 4: Optimize Layer Caching

Docker caches layers. If a layer hasn't changed, Docker reuses it. Order matters.

# BAD: Any file change busts the cache
COPY . .
RUN npm install

# GOOD: Dependencies cached unless package.json changes
COPY package.json pnpm-lock.yaml ./
RUN pnpm install
COPY . .

Dependencies change rarely. Source code changes constantly. Put stable things first.

Build time impact:

  • First build: ~4 minutes
  • Subsequent builds (no dep changes): ~45 seconds

Step 5: Configure .dockerignore

Don't copy garbage into the build context:

# Version control
.git
.gitignore

# Dependencies (each stage installs fresh)
node_modules

# Build outputs (we build fresh)
.next
out
build

# Environment files (secrets!)
.env
.env.local
.env.development
.env.production
.env.prod

# Allow only build-time env vars
!.env.build

# Development files
.vscode
.idea
*.log
*.md
tests
__tests__
coverage

# Docker files
Dockerfile
docker-compose.yml

Impact: Build context reduced from 1.2GB to 180MB.


Step 6: Separate Build-Time vs Runtime Environment Variables

This trips up many teams. Next.js has two types of env vars:

Type When Embedded Storage
NEXT_PUBLIC_* Build time (baked into JS) .env.build in Docker
Everything else Runtime Container orchestrator secrets

Wrong approach (security risk):

COPY .env.prod ./.env  # Secrets baked into your image!

Correct approach:

# Build stage - only public vars
COPY .env.build ./.env

.env.build (safe to embed):

NEXT_PUBLIC_APP_NAME=MyApp
NEXT_PUBLIC_API_URL=https://api.example.com

.env.prod (never in Docker):

DATABASE_URL=postgres://...
API_SECRET_KEY=sk_...
PAYMENT_API_KEY=...

Runtime secrets injected via orchestrator:

# Kubernetes example
envFrom:
  - secretRef:
      name: app-secrets

Step 7: Use pnpm Instead of npm/yarn

pnpm is faster and more disk-efficient:

Package Manager Install Time Disk Usage
npm 45s 500MB
yarn 38s 480MB
pnpm 22s 320MB

Enable via Corepack (built into Node 16+):

RUN corepack enable && corepack prepare [email protected] --activate

Step 8: Run as Non-Root User

Security best practice. If your container is compromised, the attacker has limited permissions:

RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nextjs

# Copy files with proper ownership
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./

USER nextjs

Step 9: Server External Packages

Some packages are only used on the server (API routes, server components) but get bundled anyway. Tell Next.js to exclude them:

// next.config.js
module.exports = {
  output: 'standalone',
  serverExternalPackages: [
    'firebase-admin',  // ~20MB, server-only SDK
    'nodemailer',      // Email sending
    'pg',              // PostgreSQL client
  ],
}

These packages use native Node.js require() instead of being webpack-bundled. Saves 10-30MB depending on your stack.


Step 10: Optimize Package Imports (Tree Shaking)

Many popular packages use "barrel exports" - one index file that re-exports everything. Without optimization, importing one icon imports the entire library.

// next.config.js
module.exports = {
  experimental: {
    optimizePackageImports: [
      'lucide-react',     // Icon library
      'date-fns',         // Date utilities
      'lodash',           // Utility functions
      'framer-motion',    // Animations
      'recharts',         // Charts
    ],
  },
}

Next.js transforms barrel imports into direct imports, enabling proper tree-shaking.

Before: import { format } from 'date-fns' pulls in entire library After: Only the format function is bundled


Step 11: Audit and Remove Unused Packages

Dead dependencies are dead weight. Find them:

# Check what's actually imported
npx depcheck

# Or manually grep for imports
grep -r "from 'package-name'" src/

We found several packages installed but never used. Removing them saved ~15MB from node_modules before the build even starts.


Step 12: node-prune (Remove Junk from node_modules)

Even after standalone tracing, node_modules contains junk: README files, changelogs, TypeScript definitions, test files, source maps.

Add node-prune to the builder stage:

# After pnpm build
RUN apk add --no-cache curl && \
    curl -sf https://gobinaries.com/tj/node-prune | sh && \
    node-prune .next/standalone/node_modules

node-prune removes:

  • *.md files
  • *.map source maps
  • __tests__ directories
  • *.d.ts type definitions
  • Documentation folders

Impact: 10-20MB savings.


Step 13: Distroless Base Image (Advanced)

For maximum security and minimum size, replace Alpine with Google's Distroless:

# Instead of Alpine (~50MB)
FROM node:24-alpine AS runner

# Use Distroless (~30MB)
FROM gcr.io/distroless/nodejs22-debian12 AS runner

Distroless contains only the Node.js runtime. No shell, no package manager, no utilities.

FROM gcr.io/distroless/nodejs22-debian12 AS runner

WORKDIR /app

ENV NODE_ENV=production
ENV PORT=3000

# Distroless runs as nonroot (uid 65532) by default
COPY --from=builder --chown=65532:65532 /app/.next/standalone ./
COPY --from=builder --chown=65532:65532 /app/.next/static ./.next/static
COPY --from=builder --chown=65532:65532 /app/public ./public

USER nonroot
EXPOSE 3000
CMD ["server.js"]

Trade-off: You can't kubectl exec into the container for debugging. Rely on logs instead.

Impact: 20MB smaller, near-zero attack surface.


Step 14: Optimize Static Assets

Your public/ folder adds directly to image size. Optimize images:

# Convert PNGs to WebP (70-90% smaller)
for f in public/*.png; do
  cwebp -q 85 "$f" -o "${f%.png}.webp"
  rm "$f"
done
Format Size Quality
PNG 400KB Lossless
WebP 50KB Visually identical

Update your code to use .webp instead of .png. Next.js Image component handles this automatically with formats: ['image/webp'].

We reduced our public folder from 11MB to 3.7MB.


The Complete Dockerfile

With all optimizations applied (including node-prune and distroless):

# =============================================================================
# Stage 1: Dependencies
# =============================================================================
FROM node:24-alpine AS deps

RUN apk add --no-cache libc6-compat
RUN corepack enable && corepack prepare [email protected] --activate

WORKDIR /app

COPY package.json pnpm-lock.yaml ./
RUN pnpm install --prod --frozen-lockfile


# =============================================================================
# Stage 2: Builder
# =============================================================================
FROM node:24-alpine AS builder

RUN corepack enable && corepack prepare [email protected] --activate

WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .
COPY .env.build ./.env

RUN pnpm install --frozen-lockfile

ENV NODE_ENV=production
RUN pnpm build

# Remove junk from traced node_modules
RUN apk add --no-cache curl && \
    curl -sf https://gobinaries.com/tj/node-prune | sh && \
    node-prune .next/standalone/node_modules


# =============================================================================
# Stage 3: Runner (Production) - Distroless
# =============================================================================
FROM gcr.io/distroless/nodejs22-debian12 AS runner

WORKDIR /app

ENV NODE_ENV=production
ENV PORT=3000

# Distroless runs as nonroot (uid 65532) by default
COPY --from=builder --chown=65532:65532 /app/.next/standalone ./
COPY --from=builder --chown=65532:65532 /app/.next/static ./.next/static
COPY --from=builder --chown=65532:65532 /app/public ./public

USER nonroot
EXPOSE 3000
CMD ["server.js"]

The Results

Metric Before After Improvement
Image Size 2.1GB 180MB 91% smaller
Pull Time 6 min 45 sec 8x faster
Build Time 8 min 2 min 4x faster
Pod Startup 90 sec 12 sec 7.5x faster
Registry Costs - - ~80% savings
Security Vulns 47 3 94% reduction
Deploy Frequency 2x/day 15x/day Enabled by speed

Quick Verification Commands

# Build the image
docker build -t myapp:latest .

# Check the size
docker images myapp:latest

# Inspect layers
docker history myapp:latest

# Run locally
docker run -p 3000:3000 myapp:latest

# Security scan
docker scout cves myapp:latest

Optimization Checklist

Before shipping to production:

Docker Basics:

  • Multi-stage build (deps → builder → runner)
  • .dockerignore excludes node_modules, .next, .git, .env files
  • Only .env.build (public vars) copied to image
  • Runtime secrets via orchestrator, not baked in
  • Non-root user in production stage
  • --frozen-lockfile for reproducible builds
  • Layer caching optimized (copy deps first)

Next.js Configuration:

  • output: 'standalone' in next.config.js
  • serverExternalPackages for server-only deps
  • optimizePackageImports for barrel-export packages

Advanced Optimizations:

  • Alpine or Distroless base image
  • node-prune to remove junk from node_modules
  • Audit and remove unused packages
  • Convert images to WebP format
  • pnpm for faster, smaller installs

Final Thoughts

Every megabyte in your Docker image costs real money and time. In Kubernetes, it compounds:

  • Larger images = slower pulls = slower scaling
  • Slower scaling = missed SLAs during traffic spikes
  • More storage = higher registry and node costs

We went from dreading deploys to shipping 15+ times per day. The 2-hour investment in optimizing our Dockerfile pays dividends on every single deploy.

These techniques aren't Next.js-specific. Multi-stage builds, Alpine base images, and layer caching apply to any Docker workflow. Start with the biggest wins (Alpine + multi-stage), then iterate.

Your CI/CD pipeline will thank you.

dockernextjsdevopsoptimizationkubernetesperformance

Subscribe to Engineering Updates

Get notified when we publish new technical deep dives. No spam, unsubscribe anytime.