# Docker Multi-Stage Builds: From 1.2 GB to 89 MB Image

> Cut Docker images by 93% with multi-stage builds. Before/after Dockerfiles for Node.js and Python, layer caching, and Trivy scanning.
> Author: Roman Belov · Published: 2026-05-22 · Source: https://futurecraft.pro/blog/docker-multistage-ai/

A typical Node.js app image built on `node:20` ships at about 1.2 GB. Inside: 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:

```dockerfile
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.

```bash
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.

```dockerfile
# 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](/blog/ai-code-review-checklist/) 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:

```dockerfile
# 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:

```dockerfile
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:

```dockerfile
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:

```dockerfile
# 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:

```dockerfile
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](/blog/ai-security-audit/). A `node:20` image carries hundreds of system packages, many with known vulnerabilities. `node:20-alpine` has a few dozen.

### Trivy: Image Scanning

```bash
# 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

```bash
# 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.

```dockerfile
# 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:

```dockerfile
# 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:

```dockerfile
# 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:

```yaml
# .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/trivy-action@0.28.0
        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](/blog/monolith-vs-microservices/) 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:

1. Run `docker images` and find anything larger than 500 MB. That's your multi-stage shortlist.
2. Add Trivy to CI. The scan takes 30 seconds and catches vulnerabilities before they ship.
3. 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](/blog/circuit-breaker-deno-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](https://belov.works).*
