How I Shrunk Our Docker Images from 1.2GB to 85MB Using Multi-Stage Builds
Last month, a junior dev on my team pushed a Docker image to our registry. The CI pipeline sat there for 11 minutes. When I checked, the image was 1.2 GB. For a Python web app with 3 dependencies.
I've been there. You probably have too. The apt-get install creep. The forgotten pip install cache. The "I'll clean it up later" that never happens.
Here's how I fixed it — and how you can too.
The Problem With Fat Images
Every Docker image bloats for the same reasons:
-
Build dependencies leak into runtime. You need
gccto compile a package, but you don't need it to run the app. -
Package manager caches.
apt-get updatedownloads index files you'll never use again. -
Layers are additive. Each
RUNline creates a new layer. Delete a file in layer 5? It's still sitting in layer 3, eating space. - "Just use ubuntu:latest" as a base image. That's 77 MB before you even start.
The fix isn't adding more rm -rf commands. It's multi-stage builds.
The Before: A Typical Bloated Dockerfile
Here's a real Python app Dockerfile I found in production:
FROM python:3.11
RUN apt-get update && apt-get install -y \
gcc g++ make \
libpq-dev libffi-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN pip install -e .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Result: 1.18 GB
The python:3.11 image alone is ~920 MB. Add gcc, g++, make, and compiled wheels, and you're past 1 GB. The app itself? Maybe 15 MB of actual code and dependencies.
The Fix: Multi-Stage Builds
Multi-stage builds let you use one image for building and another for running. Here's the pattern:
# Stage 1: Build
FROM python:3.11-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc g++ make libpq-dev libffi-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# Stage 2: Runtime
FROM python:3.11-slim
# Only copy what we actually need from the builder
COPY --from=builder /root/.local /root/.local
# Install only runtime system deps
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . .
ENV PATH="/root/.local/bin:$PATH"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Result: 210 MB — already 5.6x smaller.
What changed:
- Switched from
python:3.11(~920 MB) topython:3.11-slim(~120 MB) - Build tools (
gcc,g++,make) stay in the builder stage — never reach the final image -
pip install --userkeeps packages isolated, makingCOPY --from=builderclean -
--no-install-recommendson apt skips unnecessary packages
Taking It Further: Alpine and Distroless
210 MB is better, but we can go further.
Option 1: Alpine Base
FROM python:3.11-alpine AS builder
RUN apk add --no-cache gcc musl-dev libffi-dev postgresql-dev make
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
FROM python:3.11-alpine
RUN apk add --no-cache libpq
COPY --from=builder /root/.local /root/.local
WORKDIR /app
COPY . .
ENV PATH="/root/.local/bin:$PATH"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Result: ~85 MB
Trade-off: Alpine uses musl libc instead of glibc. If you're compiling C extensions (looking at you, psycopg2), test thoroughly before deploying.
Option 2: Distroless (for Go/Rust apps)
If you're building a compiled language, distroless is even more extreme:
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o /app/server .
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]
Result: ~14 MB for a full Go web server.
No shell. No package manager. No ls, no cat, no bash. Just your binary and a CA certificate bundle.
The Numbers: Before and After
| Strategy | Image Size | Build Time | Security Surface |
|---|---|---|---|
python:3.11 |
1.18 GB | 4 min | High (780+ packages) |
python:3.11-slim + multi-stage |
210 MB | 3 min | Medium (120+ packages) |
python:3.11-alpine + multi-stage |
85 MB | 2.5 min | Low (40+ packages) |
That's a 93% size reduction. Our CI pipeline went from 11 minutes to 90 seconds — mostly because pulling and pushing 85 MB is a lot faster than 1.2 GB.
Three Gotchas I Hit (So You Don't Have To)
1. Alpine's DNS Resolution
python:3.11-alpine doesn't include nss by default, which breaks DNS resolution in some network setups. If your app can't resolve hostnames in Kubernetes, add:
RUN apk add --no-cache nss
This adds ~2 MB but saves hours of debugging.
2. pip install --user and PATH
If you forget ENV PATH="/root/.local/bin:$PATH", your runtime container won't find uvicorn, gunicorn, or any CLI tool installed via pip. The build succeeds. The deploy fails at runtime. Ask me how I know.
3. COPY Order Matters
Docker caches layers. Put COPY requirements.txt . and pip install before COPY . .. Otherwise, changing one line of source code invalidates the pip layer cache, and you're reinstalling dependencies on every build.
The Real ROI
The image size isn't just vanity metrics. Smaller images mean:
- Faster deploys. When Kubernetes pulls a new pod image, 85 MB pulls in seconds. 1.2 GB can take minutes.
- Lower registry costs. Most registries charge by storage. AWS ECR is $0.10/GB/month. Across 50 images, that adds up.
- Smaller attack surface. Fewer packages = fewer CVEs. An alpine image has roughly 40 packages. A full ubuntu image has hundreds.
- Happier developers. Nobody wants to wait 10 minutes for a CI run.
What To Do Today
- Open your biggest Dockerfile
- Run
docker images | grep your-appand note the size - Switch the base image from
:latestto:slimor:alpine - Split build and runtime into separate stages
- Copy only what you need with
COPY --from=builder
The first time I did this, it took 20 minutes. The payoff was 93% smaller images, forever.
Have a Docker optimization story? Drop a comment — I'm always looking for new tricks.
Top comments (1)
I was particularly intrigued by the example of reducing the Docker image size from 1.2GB to 85MB using multi-stage builds, specifically the switch from
python:3.11topython:3.11-slimand then topython:3.11-alpine. The use of--no-install-recommendsand--no-cacheflags also caught my attention as a simple yet effective way to minimize package installation. I've had similar experiences with bloated images, and I appreciate how you highlighted the importance of keeping build tools and dependencies isolated to the builder stage. Have you encountered any issues with compatibility or debugging when using Alpine as a base image, especially with packages that rely on glibc?