DEV Community

Sir Max
Sir Max

Posted on

How I Shrunk Our Docker Images from 1.2GB to 85MB Using Multi-Stage Builds

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:

  1. Build dependencies leak into runtime. You need gcc to compile a package, but you don't need it to run the app.
  2. Package manager caches. apt-get update downloads index files you'll never use again.
  3. Layers are additive. Each RUN line creates a new layer. Delete a file in layer 5? It's still sitting in layer 3, eating space.
  4. "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"]
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

Result: 210 MB — already 5.6x smaller.

What changed:

  • Switched from python:3.11 (~920 MB) to python:3.11-slim (~120 MB)
  • Build tools (gcc, g++, make) stay in the builder stage — never reach the final image
  • pip install --user keeps packages isolated, making COPY --from=builder clean
  • --no-install-recommends on 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"]
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

  1. Open your biggest Dockerfile
  2. Run docker images | grep your-app and note the size
  3. Switch the base image from :latest to :slim or :alpine
  4. Split build and runtime into separate stages
  5. 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)

Collapse
 
topstar_ai profile image
Luis

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.11 to python:3.11-slim and then to python:3.11-alpine. The use of --no-install-recommends and --no-cache flags 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?