We've all been there: you whip up a simple microservice in Python, write a quick Dockerfile, run docker build, and suddenly your container image weighs almost half a gigabyte (or 1.75 GB uncompressed on disk!).
For a 15-line Flask application with two routes? That felt unacceptable.
Bloated Docker images slow down CI/CD pipelines, increase registry storage bills, consume unnecessary bandwidth during deployment, and—worst of all—expand the security attack surface with packages that have no business being in a production container.
In this project, I took a bloated baseline image and systematically redesigned it. The outcome?
🔥 Image Content Size: Reduced from 442 MB to 56.6 MB (87.19% reduction)
💾 Disk Usage: Dropped from 1.75 GB to 256 MB (85.37% reduction)
🛡️ Security: Transitioned from running as root to a hardened, non-root system user
⚡ Build Time: Dramatically improved iterative build speeds using Docker layer caching
🚀 Runtime: Replaced Flask's single-threaded dev server with a production WSGI server (Gunicorn)
Here is the full breakdown of how I did it, the pitfalls I ran into, and the key lessons you can apply to your own containers today.
🛑 The "Before": A Naive, Bloated Baseline
Here is what our initial Dockerfile.baseline looked like:
FROM python:3.12
WORKDIR /app
COPY . .
RUN apt-get update
RUN apt-get install -y curl git vim
RUN pip install --no-cache-dir -r requirements.txt
EXPOSE 5000
CMD ["python", "app.py"]
At first glance, it looks familiar. It sets a workdir, copies files, installs tools, installs dependencies, and runs the app.
Let's build it and inspect the damage:
docker build -f Dockerfile.baseline -t myapp:baseline .
docker images myapp:baseline
Output:
REPOSITORY TAG IMAGE ID CREATED SIZE
myapp baseline a1b2c3d4e5f6 10 seconds ago 442MB
Checking uncompressed disk usage with Docker desktop / inspect: 1.75 GB!
What Went Wrong Here?
-
The Base Image:
python:3.12is built on a full Debian distribution packed with compilers, header files, and utilities our web app will never call. -
Unnecessary Packages: We installed
curl,git, andvim. Why does a production container need a text editor and a version control tool? -
Separate
RUNinstructions:RUN apt-get updateandRUN apt-get installcreated two separate filesystem layers, storing temporary cache files forever in the image layer history. -
Poor Layer Caching:
COPY . .came beforeRUN pip install. Any tiny change toapp.pybusted Docker's cache and forcedpip installto execute again from scratch. -
No
.dockerignore: Test files, git history, and local virtual environments were beamed right into the Docker daemon context. -
Insecure Execution: The app runs as
root(UID 0). - Development Server: Flask’s built-in server is not built for production workloads.
Let's fix this step-by-step.
🛠️ The 6-Step Optimization Playbook
Step 1: Switch to a Minimal Base Image (-slim)
The single highest-leverage change you can make is picking the right base image.
Instead of the full python:3.12, we switched to python:3.12-slim:
FROM python:3.12-slim
Why not Alpine (
python:3.12-alpine)?
Alpine usesmusllibc instead ofglibc. While Alpine is tiny, Python packages with C extensions (like numpy, cryptography, etc.) frequently lack pre-compiled wheels for musl, triggering slow compilation during build or subtle runtime bugs. Debian slim is the sweet spot for Python: rock-solid compatibility with standardglibcwheels and a tiny footprint.
Step 2: Ruthlessly Prune Unnecessary OS Packages
Running docker history myapp:baseline exposed where the bloat lived:
-
apt-get updatelayer: ~21.3 MB -
curl git vimlayer: ~53.1 MB
Neither git nor vim belong in a running container. If you need to debug a running container, use ephemeral debugging sidecars or mount volumes—don't permanently ship development utilities to production.
We dropped the apt-get commands entirely.
Step 3: Add a Scrupulous .dockerignore
Whenever you run docker build, Docker first transfers the entire directory (the "build context") to the Docker daemon.
Without .dockerignore, you're sending .git logs, .venv, .pytest_cache, and temporary files.
We added .dockerignore:
.git
.gitignore
__pycache__
.pytest_cache
.venv
tests
*.pyc
README.md
Dockerfile*
This trimmed build context overhead and ensured sensitive or extraneous files could never leak into the container.
Step 4: Master Layer Caching (Order Matters!)
Docker caches image layers. A layer is invalidated as soon as the files it depends on change.
In our baseline:
# ❌ BAD: Edits to app.py invalidate pip install cache
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
In our optimized build:
# ✅ GOOD: Dependencies change rarely, application code changes frequently
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=appuser:appuser app.py .
Now, during local development, editing app.py results in a rebuild that finishes in under a second because the heavy pip install layer is pulled straight from cache!
Step 5: Adopt a Production WSGI Server (Gunicorn)
Flask's built-in server warns you right in the logs:
"WARNING: This is a development server. Do not use it in a production deployment."
We added gunicorn to requirements.txt and updated our entrypoint:
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]
Gunicorn gives us process management, worker concurrency, and resilient request handling without bloating image size.
Step 6: Hardening Security — Run as Non-Root
By default, Docker containers run as root. If an attacker discovers a Remote Code Execution (RCE) vulnerability inside your app, they are root inside the container, making container breakout attacks significantly easier.
We created an unprivileged system user and switched to it:
RUN useradd --create-home --shell /bin/bash appuser
COPY --chown=appuser:appuser app.py .
USER appuser
We can verify this directly on the running container:
docker exec docker-opt-optimized whoami
# Output: appuser
🏆 The "After": Hardened & Optimized Dockerfile
Here is our final, production-ready Dockerfile.optimized:
FROM python:3.12-slim
WORKDIR /app
# 1. Leverage layer caching for dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 2. Security: Create dedicated unprivileged user
RUN useradd --create-home --shell /bin/bash appuser
# 3. Copy application code with proper ownership
COPY --chown=appuser:appuser app.py .
# 4. Drop root privileges
USER appuser
EXPOSE 5000
# 5. Production WSGI server
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]
Total lines: 18. Clean, readable, and lightning-fast.
📊 The Scorecard: Baseline vs. Optimized
| Metric | Baseline | Optimized | Difference |
|---|---|---|---|
| Base Image | python:3.12 |
python:3.12-slim |
Streamlined |
| Content Size | 442 MB | 56.6 MB | -385.4 MB (-87.2%) |
| Uncompressed Disk | 1.75 GB | 256 MB | -1.49 GB (-85.4%) |
| Extra OS Tools |
curl, git, vim (~74 MB) |
Zero | Clean runtime |
| Layer Caching | Broken on every commit | Optimized | Instant rebuilds |
| User |
root (UID 0) |
appuser |
Least privilege |
| Web Server | Dev server | Gunicorn WSGI | Production ready |
🥊 Real-World Gotchas & Lessons Learned
Optimization isn't just about shaving megabytes in a spreadsheet—here are real obstacles encountered during the project:
1. The "Host Port Already in Use" Trap
When launching the container:
docker run -p 5000:5000 myapp:optimized
# Error: bind: address already in use
On macOS, port 5000 is frequently taken by the OS AirPlay Receiver service.
Solution: Understand Docker's port mapping format (HOST_PORT:CONTAINER_PORT).
We mapped -p 5001:5000, letting the internal app stay on 5000 while exposing it cleanly on host port 5001.
docker run -d --name docker-opt-optimized -p 5001:5000 docker-image-optimization:optimized
curl http://localhost:5001/health
# {"status":"healthy"}
2. "Disk Usage" vs "Content Size"
When running docker images, Docker may report one size, while docker system df -v or registry push reports another.
- Content Size: The compressed size of layers transferred across networks/registries (56.6 MB).
- Disk Usage: The uncompressed layer footprint unpacked on the host filesystem (256 MB vs 1.75 GB). Always use consistent metrics when publishing benchmarks!
3. Never Optimize Without Validation
An image with 0 MB size that crashes is useless. After trimming the image, always test both endpoints and run automated test suites:
# Automated tests via pytest
mise exec -- pytest
tests/test_app.py .. [100%]
====================== 2 passed in 0.08s =======================
💡 Quick Docker Optimization Checklist for Your Projects
Save this checklist for your next Dockerfile:
- [ ] Use
-slimor minimal official base images. - [ ] Maintain a
.dockerignorecontaining.git, caches, virtual environments, and tests. - [ ] Copy
requirements.txt/package.jsonbefore copying application code. - [ ] Remove
curl,vim,git, and build tools from final production images. - [ ] Use
--no-cache-dir(Python) or--no-cache/ clean commands when installing dependencies. - [ ] Create and switch to a non-root
USER. - [ ] Replace development servers with production application servers (Gunicorn, Uvicorn, Nginx).
- [ ] Validate image behavior with health checks and unit tests.
💬 Over to You!
Have you ever inspected your production Docker images with docker history and found unexpected surprises? What's your favorite trick for shrinking containers? Drop your thoughts in the comments below! 👇
Top comments (0)