Meta Description: I cut my Python microservice images from 1.2GB to under 90MB. Here’s my exact process for optimizing Docker image size without breaking builds.
The first time I shipped a Python microservice to production, I didn’t think twice about image size. I wrote a simple Dockerfile, pulled the default python:3.11 base image, installed my dependencies with pip, and pushed it. It worked. It also weighed in at 1.2GB.
Nobody noticed until our Kubernetes cluster started autoscaling during a traffic spike, and pods took almost 40 seconds just to pull the image before they could even start. That delay turned into failed health checks, which turned into a cascading restart loop. That was the day I actually sat down and learned how Docker image size affects real production systems — not just storage costs, but deployment speed, cold start times, attack surface, and CI/CD pipeline duration.
In this guide, I’ll walk through exactly what I changed, why each change mattered, and the tradeoffs I ran into along the way. This isn’t theoretical — every technique here is something I’ve applied to services currently running in production.
How Do I Reduce the Size of a Python Docker Image? (Quick Answer)
If you only have five minutes, here’s the short version:
- Switch from
python:3.11topython:3.11-slimor a distroless image - Use multi-stage builds to separate build dependencies from runtime dependencies
- Avoid installing unnecessary system packages (
apt-get installonly what you need) - Use
.dockerignoreto exclude files that shouldn’t be in the build context - Clean up package manager caches in the same
RUNlayer they were created in - Pin dependency versions and avoid installing dev/test dependencies in production images
- Consider Alpine only if your dependencies don’t rely on glibc-specific wheels
I’ll go through each of these in detail below, including the mistakes I made trying to apply them.
Why Docker Image Size Actually Matters
Before I get into the how, I want to address something I underestimated for years: does image size actually matter if you have decent bandwidth and storage?
In my experience, yes — for a few concrete reasons:
- Deployment speed. Every pod restart, every autoscale event, and every rolling deployment has to pull the image first. A 1.2GB image versus a 90MB image is the difference between a 30-second pull and a 2-second pull on a cold node.
- Cold start latency. If you’re running anything on AWS Lambda (via container images), Cloud Run, or Fargate, image size directly affects cold start time.
- Attack surface. Every package in your image is something that can have a CVE. Fewer packages means fewer things a security scanner flags and fewer things you have to patch.
- CI/CD pipeline time. Building and pushing large images slows down every single deploy, which adds up across hundreds of deploys a month.
- Storage and egress costs. Registries charge for storage, and pulling large images repeatedly across a fleet of nodes adds up in network costs.
None of these are dramatic on their own. But together, they compound into real operational pain — I learned this the hard way when our deploy pipeline went from 3 minutes to 11 minutes as our microservice count grew.
Step 1: Choosing the Right Base Image
This is the single highest-leverage change you can make. Here’s how the common Python base images compare:
| Base Image | Approx. Size | Pros | Cons |
|---|---|---|---|
python:3.11 | ~1GB | Full compatibility, easy debugging | Massive, includes build tools you don’t need at runtime |
python:3.11-slim | ~150MB | Good balance of size and compatibility | Missing some system libraries some packages need |
python:3.11-alpine | ~50MB | Extremely small | musl libc breaks some C-extension packages (numpy, pandas, psycopg2) |
gcr.io/distroless/python3 | ~50MB | No shell, no package manager — minimal attack surface | Harder to debug, no pip inside the final image |
I want to be upfront about something a lot of tutorials gloss over: Alpine is not always the best choice for Python. Alpine uses musl instead of glibc, and a lot of popular scientific and database packages (like numpy, pandas, psycopg2, cryptography) ship pre-built wheels compiled against glibc. On Alpine, pip either can’t find a compatible wheel and falls back to compiling from source (slow, and requires build tools you then have to install), or it just fails outright.
After testing several approaches across different services, my current default is python:3.11-slim for most services, and I reserve distroless images for services where I’ve already validated all dependencies work without a shell or package manager present.
Step 2: Multi-Stage Builds — The Biggest Win
This was the change that had the single biggest impact on my image sizes. The idea is simple: use one stage to build your dependencies (which needs compilers, headers, and build tools), and a second, clean stage to actually run your application.
Here’s a real example from a FastAPI microservice I maintain:
# ---------- Stage 1: Build ----------
FROM python:3.11-slim AS builder
WORKDIR /app
# Install build dependencies needed only for compiling packages
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies into a virtual environment
COPY requirements.txt .
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --no-cache-dir -r requirements.txt
# ---------- Stage 2: Runtime ----------
FROM python:3.11-slim AS runtime
WORKDIR /app
# Copy only the virtual environment from the build stage
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Copy application code
COPY . .
# Run as a non-root user for security
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Let me explain what’s happening here, line by line, because this is where most of the size savings come from:
- Stage 1 (
builder) installsgccandlibpq-dev, which are needed to compilepsycopg2and other C-extension packages. These tools are large —gccalone adds well over 100MB. - The virtual environment at
/opt/venvisolates everything pip installs, which makes it easy to copy cleanly into the next stage. - Stage 2 (
runtime) starts fresh frompython:3.11-slimand copies only the/opt/venvdirectory — none of the build tools come along. This is the key mechanic that makes multi-stage builds work:gcc,libpq-dev, and pip’s build caches never exist in the final image at all. - Running as a non-root
appuserisn’t about size, but I include it because I consider it a non-negotiable production practice, and it belongs in the same Dockerfile.
With this exact pattern, I took a FastAPI service from 1.1GB down to 187MB — before I’d even touched dependency pruning.
Step 3: Cleaning Up in the Same Layer
One mistake I made early on: I’d run apt-get update && apt-get install -y some-package, and then in a separate RUN command later, do rm -rf /var/lib/apt/lists/*. This doesn’t actually save space, because Docker layers are immutable — each RUN command creates a new layer, and deleting files in a later layer doesn’t remove them from the earlier layer’s size on disk.
The fix is to combine the install and cleanup into a single RUN instruction:
# Wrong — cleanup happens in a separate layer, doesn't reduce image size
RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# Right — install and cleanup happen in the same layer
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
The same principle applies to pip. Use --no-cache-dir so pip doesn’t leave its download cache sitting in the layer:
RUN pip install --no-cache-dir -r requirements.txt
This one flag alone can shave 50-100MB off images with heavier dependency lists like pandas, torch, or scikit-learn.
Step 4: Trim Your Dependencies
This step is easy to skip, but it’s often where the real bloat lives. I’ve seen production requirements.txt files carrying pytest, black, ipython, and jupyter — none of which have any business being in a production image.
My approach now is to split dependencies into two files:
requirements.txt # production dependencies only
requirements-dev.txt # -r requirements.txt + testing/linting/dev tools
And in the Dockerfile, I only ever install requirements.txt:
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
I also periodically audit dependencies with pip list inside the running container and cross-reference against what’s actually imported in the codebase. It’s not glamorous work, but I’ve found unused packages in almost every service I’ve audited this way.
Step 5: Use a .dockerignore File
This one doesn’t shrink your runtime image directly (since it doesn’t affect what gets installed), but it dramatically speeds up builds and prevents you from accidentally leaking local files — .env files, __pycache__, virtual environments, and .git history — into your build context or, worse, into an image layer.
Here’s the .dockerignore I use as a baseline for Python services:
__pycache__
*.pyc
*.pyo
.git
.gitignore
.env
.venv
venv/
*.egg-info
.pytest_cache
.mypy_cache
tests/
README.md
Dockerfile
.dockerignore
After deploying this in production, I noticed build context upload times drop noticeably on services that had accumulated large .git histories or stray virtual environment folders.
Real Example: Before and After
Here’s a side-by-side of an actual service I optimized — a Flask-based internal API with a PostgreSQL dependency:
| Metric | Before | After |
|---|---|---|
| Base image | python:3.11 | python:3.11-slim (multi-stage) |
| Final image size | 1.21GB | 143MB |
| Build time (cached) | 48s | 19s |
| Cold pull time (new node) | ~32s | ~4s |
| CVEs flagged by Trivy scan | 87 | 14 |
The CVE reduction alone was worth the effort — most of those flagged vulnerabilities came from build tools and system libraries that had no business being in the runtime image in the first place.
Common Mistakes I See (and Made Myself)
- Copying the entire project before installing dependencies. If you
COPY . .beforepip install, Docker invalidates the dependency layer cache on every single code change, forcing a full reinstall. Always copyrequirements.txtfirst, install, then copy the rest of the code. - Not pinning versions. Unpinned dependencies can silently pull in a much larger package version between builds. I pin everything with
==in production requirements files. - Switching to Alpine without testing. I’ve lost hours debugging
psycopg2build failures on Alpine that simply didn’t exist onslim. Always test the full dependency install on the exact base image before committing to it. - Forgetting
--no-install-recommends. Without this flag,apt-get installpulls in a surprising number of “recommended” packages you didn’t ask for. - Leaving the build stage’s shell tools in a “just in case I need them” runtime image. If you need a debug shell, use a sidecar or
kubectl debugwith an ephemeral container instead of bloating your production image permanently.
Best Practices Checklist
Here’s the checklist I run through before merging any Dockerfile change:
- [ ] Using
slimor distroless base image where dependencies allow - [ ] Multi-stage build separates build tools from runtime
- [ ]
requirements.txtcontains only production dependencies - [ ]
pip installuses--no-cache-dir - [ ]
apt-getinstall and cleanup happen in the sameRUNlayer - [ ]
.dockerignoreexcludes.git,__pycache__,.env, and virtual environments - [ ] Dependency layer is cached separately from application code layer
- [ ] Container runs as a non-root user
- [ ] Image scanned with a tool like Trivy or Grype before deployment
- [ ] Final image size documented and tracked over time (I keep this in a simple CI job that fails if size regresses beyond a threshold)
Performance and Security Considerations
Smaller images aren’t just about disk space — they have a direct security benefit. Every package that isn’t in your final image is a package that can’t be exploited. Distroless images take this furthest by removing the shell entirely, which means even if an attacker achieves code execution, they don’t have sh, bash, curl, or a package manager to pivot with.
That said, I’ve learned to weigh this against operational reality. Distroless images are harder to debug when something goes wrong in production — you can’t kubectl exec into a shell that doesn’t exist. My rule of thumb: I use distroless for stateless, well-tested services with mature observability (structured logging, tracing), and I stick with slim for services still in active development where I need to debug interactively.
Frequently Asked Questions
Does using Alpine always produce the smallest Python Docker image?
Not necessarily in practice. While the Alpine base image itself is smaller, packages that need to compile C extensions against musl instead of glibc can end up requiring extra build tools installed on top, sometimes closing the size gap — and introducing compatibility risk for packages like pandas, numpy, or psycopg2.
What’s the difference between python:3.11-slim and python:3.11-alpine?slim is a Debian-based image with unnecessary packages stripped out, but it still uses glibc, so most Python wheels install without issues. alpine uses musl libc and is smaller, but some packages need to be compiled from source, which can increase both build time and final image size in some cases.
Do multi-stage builds slow down my CI/CD pipeline?
In my experience, no — they usually speed it up, since Docker’s build cache can reuse the dependency-installation layer across builds when requirements.txt hasn’t changed, and the final image being smaller means faster pushes and pulls.
Should I use distroless images for every Python microservice?
I don’t recommend it universally. Distroless images remove the shell and package manager, which improves security but makes debugging in production harder. I reserve them for stable, well-observed services rather than ones still under active iteration.
How do I check what’s actually taking up space in my Docker image?
I use docker history <image> to see layer-by-layer size, and tools like dive (a CLI tool for exploring image layers interactively) to visually inspect exactly which files are contributing to size.
Will reducing image size improve my application’s runtime performance?
Not directly — image size affects pull and cold-start time, not CPU or memory performance once the container is running. That said, smaller images often correlate with fewer installed packages competing for memory and a smaller attack surface, which is a meaningful indirect benefit.
Is it worth optimizing image size for a small internal tool with low traffic?
It depends on your priorities. For low-traffic internal tools, I usually still apply the basic wins (multi-stage build, slim base, .dockerignore) since they cost almost nothing, but I don’t spend extra time chasing every last megabyte the way I would for a high-scale, autoscaling production service.
Final Summary
Optimizing Docker image size for Python microservices comes down to a handful of consistently high-leverage changes: pick a lean base image that matches your actual dependency needs, separate build-time tools from runtime with multi-stage builds, clean up package manager artifacts in the same layer they’re created, trim your dependency list to production-only packages, and use a proper .dockerignore file.
None of these changes are individually complicated, but together they took my services from over a gigabyte down to under 100MB, cut CI/CD pipeline time meaningfully, and reduced the number of CVEs flagged in every security scan. If you’re running Python microservices in any kind of autoscaling or container-orchestrated environment, this is time well spent.
Want more practical DevOps and backend engineering guides like this one? Check out more tutorials on Docker, Kubernetes, and cloud infrastructure over on SpiritCode.blog — I write about the real-world problems I run into building and deploying production systems, not just the theory.
