DEV Community

Cover image for How I Slashed a Docker Image from 442 MB to 56 MB (87% Cut) and Hardened It for Production
Alan Varghese
Alan Varghese

Posted on

How I Slashed a Docker Image from 442 MB to 56 MB (87% Cut) and Hardened It for Production

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

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

Output:

REPOSITORY   TAG        IMAGE ID       CREATED          SIZE
myapp        baseline   a1b2c3d4e5f6   10 seconds ago   442MB
Enter fullscreen mode Exit fullscreen mode

Checking uncompressed disk usage with Docker desktop / inspect: 1.75 GB!

What Went Wrong Here?

  1. The Base Image: python:3.12 is built on a full Debian distribution packed with compilers, header files, and utilities our web app will never call.
  2. Unnecessary Packages: We installed curl, git, and vim. Why does a production container need a text editor and a version control tool?
  3. Separate RUN instructions: RUN apt-get update and RUN apt-get install created two separate filesystem layers, storing temporary cache files forever in the image layer history.
  4. Poor Layer Caching: COPY . . came before RUN pip install. Any tiny change to app.py busted Docker's cache and forced pip install to execute again from scratch.
  5. No .dockerignore: Test files, git history, and local virtual environments were beamed right into the Docker daemon context.
  6. Insecure Execution: The app runs as root (UID 0).
  7. 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
Enter fullscreen mode Exit fullscreen mode

Why not Alpine (python:3.12-alpine)?

Alpine uses musl libc instead of glibc. 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 standard glibc wheels and a tiny footprint.

Step 2: Ruthlessly Prune Unnecessary OS Packages

Running docker history myapp:baseline exposed where the bloat lived:

  • apt-get update layer: ~21.3 MB
  • curl git vim layer: ~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*
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

We can verify this directly on the running container:

docker exec docker-opt-optimized whoami
# Output: appuser
Enter fullscreen mode Exit fullscreen mode

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

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

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

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
Enter fullscreen mode Exit fullscreen mode
tests/test_app.py ..                             [100%]
====================== 2 passed in 0.08s =======================
Enter fullscreen mode Exit fullscreen mode

💡 Quick Docker Optimization Checklist for Your Projects

Save this checklist for your next Dockerfile:

  • [ ] Use -slim or minimal official base images.
  • [ ] Maintain a .dockerignore containing .git, caches, virtual environments, and tests.
  • [ ] Copy requirements.txt / package.json before 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)