DEV Community

Cover image for The Alpine Mirage: How Upgrading Python Broke My Build and Led to a Truer Security Posture
Rohith Pavithran
Rohith Pavithran

Posted on

The Alpine Mirage: How Upgrading Python Broke My Build and Led to a Truer Security Posture

Summer Bug Smash: Smash Stories 🐛🛹

This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.

The Initial Goal: "Upgrade and Secure"

Like many developers, I recently fell into the trap of assuming that "smaller is always better, and newer is always safer." I decided to upgrade my terminal-based web UI project, py_terminal, to the bleeding-edge python:3.15-rc-alpine Docker base image.

The logic was sound:

  1. Alpine Linux has a much smaller footprint, meaning a smaller attack surface.
  2. Python 3.15 Release Candidate would give me early access to performance improvements and patches.

What followed was a cascading series of build failures that taught me a valuable lesson about container architecture, Python's C-API, and what actually makes a container secure.


The Descent into Dependency Hell

The moment I pushed the Dockerfile update and ran docker build, the pipeline exploded.

1. The Missing Wheels

The first error was abrupt:

ERROR: No matching distribution found for litellm==1.93.0
Enter fullscreen mode Exit fullscreen mode

Because I was combining a release candidate of Python (3.15-rc) with Alpine (which uses musl libc instead of the standard glibc), pre-compiled binaries (wheels) simply didn't exist for several of my packages. pip was forced to download raw source code and build from scratch.

2. The Rust Compiler (Wait, Rust?)

One of litellm's underlying dependencies is fastuuid, which is written in Rust. Because pip was building from source, it attempted to download the Rust toolchain (cargo). It immediately failed:

Error loading shared library libgcc_s.so.1: No such file or directory
Enter fullscreen mode Exit fullscreen mode

Because Alpine is so incredibly stripped down, it didn't even have the basic C runtime library (libgcc) required to run the Rust compiler.

3. Fighting the PyO3 API

Determined to win, I added the heavy build tools to Alpine (apk add build-base cargo libffi-dev). The build got further, but then crashed while compiling tiktoken and pydantic-core.

The bridge between Rust and Python is handled by a library called PyO3. It explicitly rejected Python 3.15 because it was too new! I bypassed this by injecting an environment variable: PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1.

I thought I had won, until the final boss appeared:

error[E0609]: no field `tp_new` on type `PyTypeObject`
Enter fullscreen mode Exit fullscreen mode

Python 3.15 had made fundamental changes to its internal C-API, hiding fields like tp_new and tp_base. The Rust bindings in pydantic-core were incompatible at a structural level. It was physically impossible to build.


The Pivot: What Does "Secure" Actually Mean?

I took a step back. I realized that by trying to use Alpine to create a "secure, minimal" container, I was actually doing the exact opposite.

If I wanted to stay on Alpine, I was going to have to leave heavy compilers (GCC, Make, Rust) inside my container or build complex multi-stage pipelines. Compilers inside production images are a massive security risk.

I pivoted back to python:3.12-slim (a Debian-based image) and focused on actual security best practices rather than just chasing a smaller megabyte footprint.

The Fix

Here is the final Dockerfile we settled on:

FROM python:3.12-slim

# 1. Create a non-root user for security
RUN groupadd -r appuser && useradd -r -g appuser appuser

WORKDIR /app

COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY . ./

# 2. Change ownership of the app to the non-root user
RUN chown -R appuser:appuser /app

# 3. Switch to the non-root user before running the app
USER appuser

ENV PYTHONUNBUFFERED=1
ENTRYPOINT ["python", "terminal_web/main.py"]
Enter fullscreen mode Exit fullscreen mode

Instead of fighting musl compilation, we leaned on Debian's pre-compiled wheels (keeping the image compiler-free). Then, we stripped the application of root privileges. If a vulnerability is ever exploited in my app, the attacker is trapped inside the restricted appuser, heavily minimizing the blast radius.


Going the Extra Mile: Trivy Automation

To ensure we didn't blindly introduce vulnerable packages again, we integrated Trivy scanning.

Not only did we add Trivy to our GitHub Actions CI pipeline to fail the build if CRITICAL or HIGH vulnerabilities are found, but we also built a custom AI Agent Skill to automate local reporting.

Now, with a single command, my local environment spins up a background Trivy scan, extracts the JSON data, and parses it into a beautiful Markdown and HTML table, ensuring that no vulnerable library sneaks in before I even push to the remote.

What I Learned

  1. Alpine is a double-edged sword for Python: Unless you are prepared to build multi-stage Dockerfiles and compile C/Rust extensions manually, Debian-slim images are often much safer and significantly faster to build.
  2. "Small" does not equal "Secure": Adding build-tools to an image negates the security benefits of its small size.
  3. Identity is everything: Running your container as a non-root user is one of the single highest-impact security improvements you can make, regardless of your base image.

This Bug Smash journey was frustrating, but the end result is a highly stable, automatically-scanned, and truly secure container architecture.

Top comments (0)