Mastering Python Containers: A Tale of Two Fixes
Moving a FastAPI stack from a standard Dockerfile to a high-performance uv build isn’t just about speed—it’s about avoiding the “Bloated Image” and the “Broken Path” traps. Here is how we solved both.
Part 1: The Size Battle (From 1GB to 350MB)
Most Python images are bloated because they treat the container like a virtual machine rather than a specialized runtime. We achieved a 65% reduction in size using three specific tactics:
- The Multi-Stage “Surgical” Copy
In a single-stage build, your image keeps every tool used to compile dependencies (like gcc, g++, and python3-dev). These are massive.
Fix: Build everything in a builder stage, then copy only the resulting virtual environment into a clean runtime stage.
- Selective Shared Libraries
You don’t need a compiler to run Python, but you do need the shared libraries that C-extensions (like psycopg2 or cryptography) link to. Instead of installing full development headers in the final image, we only install the lightweight runtime versions: libpq, libstdc++, and openssl.
- The
.dockerignoreGuard
The most common cause of 1GB images? Running COPY . . and accidentally pulling in your local .venv, .git folder, and pycache.
Fix: A strict .dockerignore ensures that the only environment inside the container is the one built specifically for the container.
Part 2: The Hardcoded Shebang (Fixing “File Not Found”)
Even with a small image, you might encounter a confusing error:
bash: /opt/venv/bin/<binary>: cannot execute: required file not found
The “Ghost” Path Problem
When uv or pip installs a package like Gunicorn, it creates an executable script. The very first line of that script (the Shebang) points to the absolute path of the Python interpreter used during installation.
If you build in /build/.venv but move it to /opt/venv for production, Gunicorn will still try to find Python at /build/.... Because that folder doesn’t exist in the final stage, you get a “file not found” error.
The Fix: Path-Matching
The solution is to ensure the Builder and Runtime stages use the exact same directory structure. By setting WORKDIR /opt/venv in both stages, the internal paths created by uv remain valid when the environment is moved.
Dockerfile
# Builder: Build in the final destination
WORKDIR /opt/venv
RUN uv sync --frozen ...
# Runtime: Copy to the exact same path
COPY --from=builder /opt/venv/.venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
The Result
By combining uv’s speed with multi-stage path matching, we ended up with an image that is:
- Reproducible:
uv.lockensures zero dependency drift. - Tiny: Alpine-based with zero build-tool bloat.
- Functional: No broken shebangs or missing shared libraries.