How We Reduced Our Next.js Docker Image from 2.1GB to 180MB
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.
Pasha S
Engineering

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:
--prodflag: Only production dependencies--frozen-lockfile: Fails if lockfile is outdated (reproducible builds)- Copy only
package.jsonand 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:
*.mdfiles*.mapsource maps__tests__directories*.d.tstype 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)
-
.dockerignoreexcludes 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-lockfilefor reproducible builds - Layer caching optimized (copy deps first)
Next.js Configuration:
-
output: 'standalone'in next.config.js -
serverExternalPackagesfor server-only deps -
optimizePackageImportsfor 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.
Subscribe to Engineering Updates
Get notified when we publish new technical deep dives. No spam, unsubscribe anytime.