Blog
4 min read

How to Reduce Docker Image Size: From 1.5 GB to Under 200 MB

Big Docker images are slow to build, push, pull and deploy. The techniques that actually shrink them: slim base images, multi-stage builds, .dockerignore, production-only dependencies, layer ordering, cache cleanup — with Node.js and Python examples and a way to see what's taking space.

A first Dockerfile often produces an image well over a gigabyte. That size costs you on every build, push, pull and deploy — and every extra package is extra attack surface. Most images can shrink by 80–90% with a handful of techniques, roughly in order of impact.

Step 0: See what's taking space

docker images myapp
docker history myapp:latest

docker history shows each layer and its size, so you can see which instruction added the bulk. For a deeper look, the open-source tool dive lets you browse each layer's files interactively.

1. Use a smaller base image

The base image is often most of the size:

Base image Rough size
node:24 (Debian, full) ~1 GB+
node:24-slim (Debian, minimal) ~200 MB
node:24-alpine (Alpine Linux) ~150 MB
python:3.13 ~1 GB
python:3.13-slim ~150 MB

-slim is usually the best default. It's Debian-based, so it behaves like most servers, just without compilers and extras.

Alpine is smaller still, but uses a different C library (musl instead of glibc). Some packages with native code — certain Python wheels, some Node native modules — must compile from source or behave differently on Alpine, which can make builds slower and occasionally cause subtle bugs. Use it when you've tested it.

Distroless images (containing only your runtime, no shell or package manager) are smaller and more secure again, but harder to debug.

2. Use a multi-stage build

The biggest structural win. Build tools, dev dependencies and source files are needed to build your app, not to run it. A multi-stage build uses one stage to build and copies only the results into a clean final stage.

# --- Build stage ---
FROM node:24-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# --- Runtime stage ---
FROM node:24-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

The final image contains production dependencies and built output only — no TypeScript compiler, test framework, or source files. For Next.js, the output: "standalone" setting produces a minimal server bundle designed for exactly this. (Writing a production Dockerfile for Node.js covers the full version.)

3. Add a .dockerignore

Without it, COPY . . sends everything to the build — node_modules, .git, logs, local env files:

node_modules
.git
dist
coverage
*.log
.env*
Dockerfile
docker-compose*.yml

This shrinks the build context, speeds up builds, and keeps secrets out of the image. (What is .gitignore? — same idea.)

4. Install only production dependencies

  • Node: npm ci --omit=dev (or pnpm install --prod).
  • Python: keep dev tools out of the runtime requirements; install with pip install --no-cache-dir -r requirements.txt.

Audit your dependencies too — a forgotten heavy package (a headless browser, a full AWS SDK) can add hundreds of megabytes.

5. Clean up in the same layer

Each RUN creates a layer. Deleting files in a later layer doesn't shrink the image — they still exist in the earlier one. Clean up in the same RUN:

RUN apt-get update \
 && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*

--no-install-recommends avoids pulling in optional packages.

6. Order layers for caching

Not smaller, but faster: copy dependency files and install before copying source code. Then changing a source file doesn't invalidate the slow npm ci layer. The multi-stage example above does this.

Python example

FROM python:3.13-slim AS build
WORKDIR /app
RUN pip install --no-cache-dir uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev

FROM python:3.13-slim
WORKDIR /app
COPY --from=build /app/.venv /app/.venv
COPY . .
ENV PATH="/app/.venv/bin:$PATH"
USER nobody
CMD ["python", "-m", "myapp"]

The virtual environment is built in one stage and copied into a clean one. (Python virtual environments.)

Don't break things chasing megabytes

  • Removing a shell makes debugging a live container much harder.
  • Alpine compatibility issues can cost more time than the saved megabytes.
  • Keep curl or similar if your health check needs it.

Security bonus

Smaller images have fewer packages, so fewer vulnerabilities for scanners to flag and attackers to use. Running as a non-root USER (as in both examples) limits the damage if the app is compromised. (Hardening containers.)

The summary

  • Find the bulk with docker history or dive.
  • Use -slim bases (Alpine only when tested).
  • Multi-stage builds: build in one stage, run from a clean one.
  • .dockerignore, production-only dependencies, and cleanup in the same RUN.
  • Order layers so dependencies are cached.

EasySpawn servers run your app on a managed virtual machine with your dependencies kept on persistent storage — and Claude Code can still trim the Dockerfile you ship elsewhere, checking that the app starts after every change. See how it works or join the waitlist.

Related: What Is Docker? · Docker Compose for Local Development · Docker Volumes vs Bind Mounts · Containers vs Virtual Machines · Docker Image Layers and OverlayFS

Keep reading