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(orpnpm 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
curlor 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 historyordive. - Use
-slimbases (Alpine only when tested). - Multi-stage builds: build in one stage, run from a clean one.
.dockerignore, production-only dependencies, and cleanup in the sameRUN.- 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
Writing a Production Dockerfile for a Node.js App
The Dockerfile an AI tool writes usually works — and ships a 1.5 GB image running as root that ignores shutdown signals and leaks build secrets into its layers. A line-by-line production Dockerfile: multi-stage builds, layer caching, non-root users, signal handling, secrets, and health checks.
How to Deploy a FastAPI App to Production
Running uvicorn main:app --reload is for development. A production FastAPI deploy needs worker processes, a process manager or container, a reverse proxy with HTTPS, proper settings, migrations, and health checks. Step-by-step options for a VPS and Docker.