Docker Multi-Stage Builds: From 1.2 GB to 89 MB Image
What is Docker multi-stage build optimization?
Docker multi-stage build optimization is the practice of splitting a Dockerfile into separate build and runtime stages so the final image contains only compiled artifacts and runtime dependencies — not the compiler, devDependencies, or OS build tools. It matters because a standard node:20 image weighs ~1.24 GB while a properly optimized multi-stage Alpine image weighs ~89 MB, a 93% reduction that directly cuts deployment time, attack surface, and infrastructure costs. AI assistants accelerate the process by generating and analyzing Dockerfiles in seconds, catching non-obvious issues like unnecessary Prisma engine binaries or misconfigured layer caching order.
TL;DR
- -Switching from node:20 to node:20-alpine as the base image alone cuts ~910 MB — Alpine Linux is ~5 MB versus ~910 MB of Debian.
- -Layer cache invalidation is controlled entirely by instruction order: COPY package.json before COPY . ensures npm ci only reruns on dependency changes, not on every code commit.
- -BuildKit mount caching (--mount=type=cache) persists the npm or pip cache between builds, reducing incremental rebuild time significantly for partial dependency updates.
- -Prisma ORM generates engine binaries for every platform by default — deleting all but the target architecture (linux-musl) removes 50–80 MB of binary dead weight.
- -CI/CD size gates (rejecting images over 150 MB) and automated Trivy scanning prevent optimization regressions from accumulating silently over time.
I ran docker images on a client’s CI pipeline and found their Node.js app shipping at 1.2 GB. A full Debian installation, gcc, make, Python — none of it used at runtime. Every extra package widens the attack surface and slows every single deploy.
Multi-stage builds fix this by splitting the build into stages: one container compiles, another runs. The final image only carries the runtime and build artifacts. AI assistants cut the time to get there — generating optimized Dockerfiles in seconds instead of hours of trial and error.
What follows: steps for analyzing a bloated image, multi-stage refactoring, AI prompts for optimization, layer caching, and security scanning.
Anatomy of a Bloated Docker Image
A typical Dockerfile for a Node.js application looks like this:
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]
Result: an image of ~1.2 GB. Let’s break down what’s inside.
docker images my-app
# REPOSITORY TAG IMAGE ID SIZE
# my-app latest a1b2c3d4e5f6 1.24GB
docker history my-app --human --no-trunc | head -20
Image composition by layer:
| Layer | Size | Contents |
|---|---|---|
| Base image (node:20) | ~910 MB | Debian, Python, gcc, make, curl, git |
| npm install | ~280 MB | node_modules (dev + prod) |
| COPY + build | ~50 MB | Source code, tests, IDE configs |
Three problems in one Dockerfile. The base image drags in a full Debian with compilation tools. npm install pulls devDependencies you only need at build time. COPY . sends everything — .git, tests, editor configs — straight into the image.
Multi-Stage Build: Separating Build and Runtime
A multi-stage build uses multiple FROM instructions in a single Dockerfile. Each FROM starts a new stage. The final image only gets what you explicitly copy from earlier stages — nothing else makes it through.
# Stage 1: build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --include=dev
COPY . .
RUN npm run build
RUN npm prune --omit=dev
# Stage 2: production
FROM node:20-alpine AS production
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]
Result: 89 MB. A 93% reduction.
What changed:
Base image. node:20-alpine instead of node:20. Alpine Linux is ~5 MB vs. ~910 MB of Debian. It ships with just the essentials: musl libc, busybox, apk.
Stage separation. The builder stage installs devDependencies, compiles TypeScript, and runs the linter. The production stage takes only compiled files and production dependencies.
Dropping devDependencies. npm prune --omit=dev after the build strips typescript, eslint, jest, and dozens of packages you don’t need at runtime.
Non-root user. The container runs as an unprivileged user. If an attacker gets RCE, they’re stuck with appuser permissions — not root.
Prompts for AI Dockerfile Optimization
AI assistants are good at analyzing Dockerfiles and suggesting improvements. The key is the right prompt: a specific task, application context, and clear targets.
Prompt 1: Analyzing an Existing Dockerfile
Analyze this Dockerfile. The application is a Node.js REST API built on Express
with TypeScript. Database: PostgreSQL via Prisma ORM.
Find:
1. Layers that invalidate the cache on every commit
2. Files and packages not needed at runtime
3. Security issues (running as root, unnecessary capabilities)
4. Opportunities for multi-stage optimization
Current image size: 1.2 GB. Target: < 150 MB.
[paste Dockerfile]
AI catches things that are easy to miss by hand — similar to how AI code review surfaces bugs humans skip. For example: Prisma generates binary engine files for every platform. In production you only need linux-musl-arm64-openssl-3.0.x (or whatever matches your target arch). The rest is dead weight — 50–80 MB of it.
Prompt 2: Generating an Optimized Dockerfile
Generate a production Dockerfile for:
- Node.js 20 + TypeScript REST API
- Prisma ORM (PostgreSQL)
- Requirements: multi-stage, alpine base, non-root user,
healthcheck, minimal attack surface
- Prisma: keep only the linux-musl engine
- Layer caching: package.json and prisma/schema.prisma
copied separately before npm ci
Prompt 3: Optimizing for a Specific Stack
Optimize a multi-stage Dockerfile for a Python FastAPI application:
- Use python:3.12-slim instead of alpine (musl breaks numpy/pandas)
- Copy the entire virtual environment to the second stage
- Remove pip cache, __pycache__, .pyc files
- Install only runtime dependencies via pip --no-deps
- Add PYTHONDONTWRITEBYTECODE=1, PYTHONUNBUFFERED=1
Python’s multi-stage build looks a bit different. Alpine’s musl libc breaks numpy, pandas, and a lot of other scientific packages. Use slim instead:
# Stage 1: build
FROM python:3.12-slim AS builder
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Stage 2: production
FROM python:3.12-slim AS production
WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY ./app ./app
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Layer Caching: Instruction Order Determines Build Speed
Docker caches each layer. If a layer hasn’t changed, it reuses the cache. Change one layer and everything after it rebuilds from scratch.
Wrong order:
COPY . .
RUN npm ci
RUN npm run build
Any code change busts the COPY . layer, so npm ci runs from scratch every time. That’s 40–120 seconds wasted on every build.
Correct order:
COPY package.json package-lock.json ./
RUN npm ci
COPY prisma/schema.prisma ./prisma/
RUN npx prisma generate
COPY . .
RUN npm run build
npm ci only reruns when package.json or package-lock.json actually changes. The Prisma client only regenerates when the schema changes. App code changes constantly, so it goes last.
Advanced Caching with BuildKit
Docker BuildKit lets you mount a cache directory that persists between rebuilds:
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
/root/.npm survives between builds. When package-lock.json changes, npm still pulls unchanged packages from the cache rather than re-downloading them. For partial dependency updates, the rebuild time drops noticeably.
For Python, the equivalent is:
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
.dockerignore: The First Line of Optimization
.dockerignore cuts what gets sent to the Docker daemon as the build context. Without it, COPY . ships .git (tens of MB), node_modules (hundreds of MB), test data — all of it.
# .dockerignore
.git
.gitignore
node_modules
npm-debug.log
Dockerfile*
docker-compose*
.dockerignore
.env*
*.md
LICENSE
.vscode
.idea
coverage
__tests__
*.test.ts
*.spec.ts
.husky
.eslintrc*
.prettierrc*
tsconfig.json
jest.config.*
AI prompt:
Generate a .dockerignore for a Node.js TypeScript project.
Exclude everything not needed in a production image:
IDE configs, tests, documentation, CI files, dev dependencies.
Keep: package.json, package-lock.json, src/, prisma/, scripts/migrate.sh.
Security Scanning: Catching Vulnerabilities Before Deployment
Smaller images are also more secure — fewer packages means fewer CVEs. For a deeper look at automated security checks, see the AI security audit checklist. A node:20 image carries hundreds of system packages, many with known vulnerabilities. node:20-alpine has a few dozen.
Trivy: Image Scanning
# Install
brew install aquasecurity/trivy/trivy
# Scan the image
trivy image my-app:latest
# Critical and high vulnerabilities only
trivy image --severity CRITICAL,HIGH my-app:latest
# Scan the Dockerfile (without building)
trivy config Dockerfile
Sample output:
my-app:latest (alpine 3.19.1)
Total: 0 (CRITICAL: 0, HIGH: 0)
Node.js (node_modules/package-lock.json)
Total: 2 (CRITICAL: 0, HIGH: 1, MEDIUM: 1)
┌─────────────────┬──────────────────┬──────────┬────────────┐
│ Library │ Vulnerability │ Severity │ Version │
├─────────────────┼──────────────────┼──────────┼────────────┤
│ jsonwebtoken │ CVE-2024-XXXXX │ HIGH │ 9.0.0 │
│ semver │ CVE-2024-YYYYY │ MEDIUM │ 7.5.3 │
└─────────────────┴──────────────────┴──────────┴────────────┘
Docker Scout: Built-In Scanning
# Analyze the image
docker scout cves my-app:latest
# Recommendations for updating the base image
docker scout recommendations my-app:latest
# Compare two versions
docker scout compare my-app:latest --to my-app:previous
AI Prompt for Analyzing Scan Results
Here are the Trivy scan results for my Docker image.
For each vulnerability, determine:
1. Whether it is exploitable in the context of a Node.js REST API
2. Whether a fix is available (package update)
3. Remediation priority (critical / can be deferred)
Ignore vulnerabilities in packages that are not directly imported.
[paste trivy output]
Distroless: Even Smaller, Even More Secure
Google Distroless images have no shell, no package manager, no OS utilities. Just the runtime. That’s the smallest possible attack surface.
# Stage 1: build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --omit=dev
# Stage 2: distroless
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
EXPOSE 3000
CMD ["dist/index.js"]
Result: ~70 MB. There’s no shell to exec into, no apt, no curl. If an attacker gets code execution, there’s nothing to pivot with.
The tradeoff: debugging gets harder. The fix is the debug variant of the distroless image for staging:
# For staging with shell access
FROM gcr.io/distroless/nodejs20-debian12:debug
The Final Optimized Dockerfile
Here’s everything combined. A real Dockerfile for Node.js + Prisma + TypeScript:
# syntax=docker/dockerfile:1
# ---------- Stage 1: install dependencies ----------
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
# ---------- Stage 2: Prisma generation and build ----------
FROM deps AS builder
COPY prisma/schema.prisma ./prisma/
RUN npx prisma generate
COPY . .
RUN npm run build
RUN npm prune --omit=dev
# Remove unnecessary Prisma engines
RUN find node_modules/.prisma -name 'libquery_engine-*' \
! -name 'libquery_engine-linux-musl-*' -delete 2>/dev/null || true
# ---------- Stage 3: production ----------
FROM node:20-alpine AS production
RUN apk add --no-cache dumb-init
ENV NODE_ENV=production
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=builder --chown=app:app /app/dist ./dist
COPY --from=builder --chown=app:app /app/node_modules ./node_modules
COPY --from=builder --chown=app:app /app/package.json ./
COPY --from=builder --chown=app:app /app/prisma ./prisma
USER app
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD node -e "fetch('http://localhost:3000/health').then(r=>{if(!r.ok)throw r})"
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/index.js"]
What’s accounted for here:
| Optimization | Effect |
|---|---|
node:20-alpine | Base image ~5 MB instead of ~910 MB |
| Multi-stage (deps → builder → production) | Only production artifacts in the final image |
npm prune --omit=dev | devDependencies removed |
| Removing unnecessary Prisma engines | -50–80 MB of binary files |
--mount=type=cache | Faster repeated builds |
dumb-init | Correct signal handling (SIGTERM) |
| Non-root user | Limited privileges on RCE |
| HEALTHCHECK | Automatic restart of unhealthy containers |
--chown in COPY | Files owned by appuser, not root |
CI/CD Integration: Automatic Scanning and Size Control
Adding checks to CI keeps the gains from sliding back:
# .github/workflows/docker.yml
name: Docker Build & Scan
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t my-app:${{ github.sha }} .
- name: Check image size
run: |
SIZE=$(docker image inspect my-app:${{ github.sha }} \
--format='{{.Size}}')
MAX_SIZE=150000000 # 150 MB
if [ "$SIZE" -gt "$MAX_SIZE" ]; then
echo "Image size ${SIZE} exceeds limit ${MAX_SIZE}"
exit 1
fi
- name: Trivy vulnerability scan
uses: aquasecurity/[email protected]
with:
image-ref: my-app:${{ github.sha }}
format: table
exit-code: 1
severity: CRITICAL,HIGH
This blocks merges when the image tops 150 MB or when Trivy finds critical vulnerabilities.
Optimization Results: Before and After
| Metric | Before | After |
|---|---|---|
| Image size | ~1.24 GB | ~89 MB |
| Build time (cold) | ~4 min | ~2.5 min |
| Build time (cached) | ~4 min | ~20 sec |
| Pull time (100 Mbps) | ~100 sec | ~7 sec |
The savings scale with infrastructure. On a multi-node cluster, a smaller image directly cuts rolling update time on every deploy. If you’re choosing between deployment architectures, the monolith vs microservices comparison covers the cost tradeoffs.
What’s Next
Multi-stage builds cover the bulk of Docker image optimization. What’s left are edge cases: monorepos with shared dependencies, images with native binary modules, multi-architecture builds.
Three things to do right now:
- Run
docker imagesand find anything larger than 500 MB. That’s your multi-stage shortlist. - Add Trivy to CI. The scan takes 30 seconds and catches vulnerabilities before they ship.
- Use the prompts from this article with an AI assistant to analyze and generate optimized Dockerfiles for your stack.
The circuit breakers in edge functions article covers protecting an application at runtime. Docker image optimization works at the infrastructure level. Two different layers — both worth having.
Need help with Docker optimization and infrastructure automation? I help startups build AI products and automate processes — belov.works.
Frequently Asked Questions
Can Alpine-based images break applications that use native binary dependencies?
python:3.12-slim (Debian slim) instead of Alpine as the base image. For Node.js, most pure-JavaScript packages work fine on Alpine; native modules compiled with node-gyp may require apk add python3 make g++ in the builder stage. When in doubt, test the Alpine build with your full dependency tree before committing to it in production.
What's the tradeoff between distroless images and Alpine for production containers?
curl, no wget, no apt. The tradeoff is debugging: you cannot docker exec into a distroless container to inspect state. Alpine is more practical for teams that need to debug production incidents directly. The standard pattern is to use the distroless :debug variant in staging (which includes a shell) and the production variant in production.
How do you optimize Docker builds in a monorepo where multiple services share dependencies?
package.json (and any shared workspace packages it depends on) before running npm ci, not the entire monorepo root. This preserves layer caching: changes to service A do not invalidate the dependency installation layer for service B. In pnpm workspaces or Yarn workspaces, you also need to copy the root package.json and lockfile. Docker BuildKit's --mount=type=cache mounted to /root/.npm or the pnpm store path lets all services share a single package cache on the build host, which significantly reduces total build time across the monorepo.