Programming

Docker Images Too Big? Multi-Stage Builds Cut Mine by 80%

Our production Docker images went from 1.2GB to 180MB. Same app, same functionality, 80% smaller. Here is the exact Dockerfile pattern.

Md. Rony Ahmed · 8 min read
Docker Images Too Big? Multi-Stage Builds Cut Mine by 80%

Docker Images Too Big? Multi-Stage Builds Cut Mine by 80%



Our production Docker images went from 1.2GB to 180MB. Same app, same functionality, 80% smaller. Here's the exact Dockerfile pattern.




The Problem Nobody Talks About



Your Node.js backend works fine in development. You Dockerize it. Push to production. Then you notice:

- Build times: 8 minutes per deploy
- Registry storage: 1.2GB per image
- Pull time: 2+ minutes on new nodes
- Disk usage: 50GB after 40 deployments

Most Docker tutorials show you FROM node:latest, copy your code, run npm start, done. That image is 1.1GB before you write a single line of your own code.

I hit this exact wall at Realhub. Our CI pipeline was taking 12 minutes per build. Registry costs were climbing. New nodes took 3 minutes to pull images before they could even start serving traffic. The fix wasn't bigger servers. It was the Dockerfile.




Why Images Get Fat



A typical Node.js Dockerfile looks like this:

# The WRONG way (1.2GB+)
FROM node:20

WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

EXPOSE 3000
CMD ["npm", "start"]


What's inflating the size:
- node:20 base image: ~1.1GB
- node_modules: 400-800MB (dev dependencies included)
- Build cache, source maps, .git: 100-200MB
- Test files, docs, dev configs: 50-100MB
- Native build tools (gcc, python3, make): 200-400MB

And here's what most people miss: every layer in your Dockerfile is a permanent addition to the image. Even if you delete a file in a later layer, it still exists in the previous layer. Docker images are immutable snapshots stacked on top of each other.

# This does NOT reduce image size
RUN apt-get install -y gcc
RUN apt-get remove -y gcc


The gcc package is still in the layer history. You need multi-stage builds to actually exclude it.




What Multi-Stage Builds Actually Do



Instead of one image that does everything, you create multiple stages:

1. Builder stage: Compile, install dev dependencies, run tests
2. Production stage: Copy only the built artifacts

The final image contains only what the production stage includes. Everything from the builder stage is discarded.

# Stage 1: Build
FROM node:20-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production=false

COPY . .
RUN npm run build

# Stage 2: Production
FROM node:20-alpine AS production

WORKDIR /app

# Only copy production dependencies
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force

# Copy built assets from builder
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules

# Only runtime configs
COPY .env.production ./

USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]


Result: 1.2GB → 180MB. 85% smaller.

The builder stage is 1.1GB with all dev dependencies. The production stage is 180MB with only runtime essentials. Only the production stage gets tagged and pushed.




Python Version (FastAPI)



Python has the same problem. The python:3.11 image is 1GB+. And you need gcc, python3-dev, and build tools to compile packages like psycopg2 or cryptography.

# Stage 1: Builder
FROM python:3.11-slim AS builder

WORKDIR /app
RUN apt-get update && apt-get install -y gcc python3-dev && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

# Stage 2: Production
FROM python:3.11-slim

WORKDIR /app

# Copy installed packages from builder
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH

COPY ./app ./app

EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]


Python slim base: ~150MB. With --user install + no build tools: 220MB total.

The key detail: pip install --user installs to /root/.local instead of the system Python path. In the production stage, we copy only that directory. No gcc, no python3-dev, no build artifacts.




Combined Frontend + Backend Pattern



At Realhub, we run a React frontend + Node.js API in a single container (simpler than Kubernetes for our scale). Here's the pattern:

# Stage 1: Frontend build
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build

# Stage 2: Backend build
FROM node:20-alpine AS backend-builder
WORKDIR /app/backend
COPY backend/package*.json ./
RUN npm ci
COPY backend/ .
RUN npm run build

# Stage 3: Production runtime
FROM node:20-alpine
WORKDIR /app

# Backend dependencies only
COPY backend/package*.json ./
RUN npm ci --only=production

# Built assets
COPY --from=frontend-builder /app/frontend/dist ./public
COPY --from=backend-builder /app/backend/dist ./dist
COPY --from=backend-builder /app/backend/node_modules ./node_modules

USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]


One container, 250MB. Instead of 1.5GB with separate frontend + backend images.

The frontend builder stage includes react-scripts, typescript, eslint — all dev dependencies. The production stage only gets the compiled dist/ folder. Backend builder gets typescript, jest, nodemon. Production stage only gets compiled JavaScript + production node_modules.




The Key Principles



PrincipleWhat It MeansImpact
**Separate build deps from runtime**Don't ship `gcc`, `python3-dev`, `build-essential`-200-400MB
**Copy only built artifacts**`dist/`, `build/`, not `src/`-100-200MB
**Use `--only=production`**No devDependencies in final image-300-600MB
**Clean caches**`npm cache clean`, `rm -rf /var/lib/apt/lists/*`-50-100MB
**Non-root user**`USER node` — security + smaller attack surfaceSecurity




Real Numbers From Production



Here's what happened when I refactored our Realhub Dockerfiles:

MetricBeforeAfterChange
Image size1.2GB180MB**-85%**
Build time8m 30s2m 15s**-74%**
Registry storage50GB/40 tags7GB/40 tags**-86%**
Pull on new node2m 10s18s**-86%**
Deploy frequency2x/day (painful)10x/day (trivial)**+5x**

The biggest surprise: deploy frequency increased 5x. When builds take 2 minutes instead of 8, you deploy more often. Smaller changes, faster feedback, fewer big-bang deploys.




Common Mistakes



1. Forgetting to clean package manager caches



# Wrong — cache stays in layer
RUN npm ci --only=production

# Right — clean after install
RUN npm ci --only=production && npm cache clean --force


npm cache alone is 50-100MB. pip cache is similar. Always clean after install.

2. Copying .git and test files



# Wrong — copies everything including .git
COPY . .

# Right — use .dockerignore
# .dockerignore contents:
# .git
# node_modules
# tests/
# *.md
# .env*
COPY . .


A .git directory can be 100-500MB for large repos. Always use .dockerignore.

3. Installing build tools in production stage



# Wrong — gcc stays in final image
FROM node:20-alpine
RUN apk add --no-cache python3 make g++
RUN npm ci --only=production

# Right — install in builder, copy results
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++
RUN npm ci

FROM node:20-alpine
COPY --from=builder /app/node_modules ./node_modules


Native modules like bcrypt need python3 and g++ to compile. Compile them in the builder stage, copy the compiled .node files to production.

4. Not using .dockerignore



# .dockerignore — add this file!
node_modules
npm-debug.log
.git
.env
.env.local
.env.production
coverage
.nyc_output
dist
build
*.md
Dockerfile
docker-compose.yml
.vscode
.idea


Without .dockerignore, COPY . . includes your entire project directory. That alone can add 200-500MB.




When NOT to Use Multi-Stage



- Local development: Use docker-compose with volume mounts, not rebuilds. Multi-stage adds complexity you don't need for docker-compose up.
- Simple scripts: Single-file Python scripts — just use python:slim. The overhead of multi-stage isn't worth it for <200MB images.
- Debugging builds: When a build fails, you want to inspect the intermediate layer. Comment out the second FROM temporarily to debug.
- CI/CD with layer caching: Some CI systems cache layers aggressively. Multi-stage can complicate caching strategies. Test your specific setup.




Quick Wins (Do These Today)



1. Replace FROM node with FROM node:alpine or FROM node:slim
2. Add --only=production to your npm/pip install
3. Add a second FROM line and copy only what you need
4. Use USER directive (non-root)
5. Measure before/after with docker images

# Measure current image
docker build -t myapp:current .
docker images myapp:current --format "{{.Size}}"

# Measure optimized image
docker build -t myapp:optimized -f Dockerfile.optimized .
docker images myapp:optimized --format "{{.Size}}"





The Bottom Line



1. Docker images grow forever — every layer adds permanent size
2. Multi-stage builds discard what you don't ship — builder stage is trash
3. Alpine/slim bases save 80%node:20 is 1.1GB, node:20-alpine is 180MB
4. Production only needs runtime — no dev tools, no source code, no tests
5. Small images = fast deploys = more deploys — the compound effect is huge

I cut our deploy time from 8 minutes to 2 minutes without touching application code. The Dockerfile was the bottleneck.




Related:
- [How I Cut API Response Time by 73% With a Redis Strategy Nobody Talks About](/posts/redis-caching-strategy-api-response-time)
- [PostgreSQL Connection Pooling: From 500ms to 5ms Query Times](/posts/postgresql-connection-pooling-500ms-to-5ms)
- [I Automated My Fiverr Gig Delivery — Here's the n8n Workflow](/posts/automated-fiverr-gig-n8n-2026)
R

Md. Rony Ahmed

Backend Software Engineer · 4+ years production systems · AI microservices & distributed systems

GitHub · LinkedIn · About

Related Posts

Docker for Beginners: Complete Container Guide 2025 - From Zero to Production
Programming

Docker for Beginners: Complete Container Guide 2025 - From Zero to Production

How I Cut API Response Time by 73% With a Redis Strategy Nobody Talks About
Programming

How I Cut API Response Time by 73% With a Redis Strategy Nobody Talks About

How I Handle Authentication for 50K Users Without Losing My Mind
Cybersecurity

How I Handle Authentication for 50K Users Without Losing My Mind